diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-31 12:09:32 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-10-31 12:09:32 +0300 |
commit | 5025412fc4ab16cc7049a38d43fdc2e4095a1f87 (patch) | |
tree | ecec75618d069e02ba0ebcf36db6630150a9d073 | |
parent | 853c0c530b624a2f94ce85acbbdffc70510bdba3 (diff) |
Add latest changes from gitlab-org/gitlab@master
41 files changed, 729 insertions, 472 deletions
diff --git a/app/assets/javascripts/behaviors/markdown/render_gfm.js b/app/assets/javascripts/behaviors/markdown/render_gfm.js index a08cf48c327..ee5c0fe5ef3 100644 --- a/app/assets/javascripts/behaviors/markdown/render_gfm.js +++ b/app/assets/javascripts/behaviors/markdown/render_gfm.js @@ -15,7 +15,7 @@ $.fn.renderGFM = function renderGFM() { syntaxHighlight(this.find('.js-syntax-highlight').get()); renderKroki(this.find('.js-render-kroki[hidden]').get()); renderMath(this.find('.js-render-math')); - renderSandboxedMermaid(this.find('.js-render-mermaid').get()); + renderSandboxedMermaid(this.find('.js-render-mermaid')); renderJSONTable( Array.from(this.find('[lang="json"][data-lang-params="table"]').get()).map((e) => e.parentNode), ); diff --git a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js index 031b03e0d59..077e96b2fee 100644 --- a/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js +++ b/app/assets/javascripts/behaviors/markdown/render_sandboxed_mermaid.js @@ -1,4 +1,5 @@ -import { countBy } from 'lodash'; +import $ from 'jquery'; +import { once, countBy } from 'lodash'; import { __ } from '~/locale'; import { getBaseURL, @@ -7,8 +8,7 @@ import { joinPaths, } from '~/lib/utils/url_utility'; import { darkModeEnabled } from '~/lib/utils/color_utils'; -import { setAttributes, isElementVisible } from '~/lib/utils/dom_utils'; -import { createAlert, VARIANT_WARNING } from '~/flash'; +import { setAttributes } from '~/lib/utils/dom_utils'; import { unrestrictedPages } from './constants'; // Renders diagrams and flowcharts from text using Mermaid in any element with the @@ -27,30 +27,17 @@ import { unrestrictedPages } from './constants'; const SANDBOX_FRAME_PATH = '/-/sandbox/mermaid'; // This is an arbitrary number; Can be iterated upon when suitable. -export const MAX_CHAR_LIMIT = 2000; +const MAX_CHAR_LIMIT = 2000; // Max # of mermaid blocks that can be rendered in a page. -export const MAX_MERMAID_BLOCK_LIMIT = 50; +const MAX_MERMAID_BLOCK_LIMIT = 50; // Max # of `&` allowed in Chaining of links syntax const MAX_CHAINING_OF_LINKS_LIMIT = 30; - export const BUFFER_IFRAME_HEIGHT = 10; export const SANDBOX_ATTRIBUTES = 'allow-scripts allow-popups'; - -const ALERT_CONTAINER_CLASS = 'mermaid-alert-container'; -export const LAZY_ALERT_SHOWN_CLASS = 'lazy-alert-shown'; - // Keep a map of mermaid blocks we've already rendered. const elsProcessingMap = new WeakMap(); let renderedMermaidBlocks = 0; -/** - * Determines whether a given Mermaid diagram is visible. - * - * @param {Element} el The Mermaid DOM node - * @returns - */ -const isVisibleMermaid = (el) => el.closest('details') === null && isElementVisible(el); - function shouldLazyLoadMermaidBlock(source) { /** * If source contains `&`, which means that it might @@ -117,8 +104,8 @@ function renderMermaidEl(el, source) { ); } -function renderMermaids(els) { - if (!els.length) return; +function renderMermaids($els) { + if (!$els.length) return; const pageName = document.querySelector('body').dataset.page; @@ -127,7 +114,7 @@ function renderMermaids(els) { let renderedChars = 0; - els.forEach((el) => { + $els.each((i, el) => { // Skipping all the elements which we've already queued in requestIdleCallback if (elsProcessingMap.has(el)) { return; @@ -146,29 +133,33 @@ function renderMermaids(els) { renderedMermaidBlocks >= MAX_MERMAID_BLOCK_LIMIT || shouldLazyLoadMermaidBlock(source)) ) { - const parent = el.parentNode; - - if (!parent.classList.contains(LAZY_ALERT_SHOWN_CLASS)) { - const alertContainer = document.createElement('div'); - alertContainer.classList.add(ALERT_CONTAINER_CLASS); - alertContainer.classList.add('gl-mb-5'); - parent.after(alertContainer); - createAlert({ - message: __( - 'Warning: Displaying this diagram might cause performance issues on this page.', - ), - variant: VARIANT_WARNING, - parent: parent.parentNode, - containerSelector: `.${ALERT_CONTAINER_CLASS}`, - primaryButton: { - text: __('Display'), - clickHandler: () => { - alertContainer.remove(); - renderMermaidEl(el, source); - }, - }, - }); - parent.classList.add(LAZY_ALERT_SHOWN_CLASS); + const html = ` + <div class="alert gl-alert gl-alert-warning alert-dismissible lazy-render-mermaid-container js-lazy-render-mermaid-container fade show" role="alert"> + <div> + <div> + <div class="js-warning-text"></div> + <div class="gl-alert-actions"> + <button type="button" class="js-lazy-render-mermaid btn gl-alert-action btn-confirm btn-md gl-button">Display</button> + </div> + </div> + <button type="button" class="close" data-dismiss="alert" aria-label="Close"> + <span aria-hidden="true">×</span> + </button> + </div> + </div> + `; + + const $parent = $(el).parent(); + + if (!$parent.hasClass('lazy-alert-shown')) { + $parent.after(html); + $parent + .siblings() + .find('.js-warning-text') + .text( + __('Warning: Displaying this diagram might cause performance issues on this page.'), + ); + $parent.addClass('lazy-alert-shown'); } return; @@ -185,33 +176,37 @@ function renderMermaids(els) { }); } -export default function renderMermaid(els) { - if (!els.length) return; +const hookLazyRenderMermaidEvent = once(() => { + $(document.body).on('click', '.js-lazy-render-mermaid', function eventHandler() { + const parent = $(this).closest('.js-lazy-render-mermaid-container'); + const pre = parent.prev(); - const visibleMermaids = []; - const hiddenMermaids = []; + const el = pre.find('.js-render-mermaid'); - for (const el of els) { - if (isVisibleMermaid(el)) { - visibleMermaids.push(el); - } else { - hiddenMermaids.push(el); - } - } + parent.remove(); + + // sandbox update + const element = el.get(0); + const { source } = fixElementSource(element); + + renderMermaidEl(element, source); + }); +}); + +export default function renderMermaid($els) { + if (!$els.length) return; + + const visibleMermaids = $els.filter(function filter() { + return $(this).closest('details').length === 0 && $(this).is(':visible'); + }); renderMermaids(visibleMermaids); - hiddenMermaids.forEach((el) => { - el.closest('details').addEventListener( - 'toggle', - ({ target: details }) => { - if (details.open) { - renderMermaids([...details.querySelectorAll('.js-render-mermaid')]); - } - }, - { - once: true, - }, - ); + $els.closest('details').one('toggle', function toggle() { + if (this.open) { + renderMermaids($(this).find('.js-render-mermaid')); + } }); + + hookLazyRenderMermaidEvent(); } diff --git a/app/assets/javascripts/related_issues/components/related_issues_block.vue b/app/assets/javascripts/related_issues/components/related_issues_block.vue index 1ab41ee2f0a..c3726ebc14a 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_block.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue @@ -95,6 +95,16 @@ export default { required: false, default: true, }, + hasError: { + type: Boolean, + required: false, + default: false, + }, + itemAddFailureMessage: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -233,7 +243,7 @@ export default { <div v-if="isFormVisible" class="js-add-related-issues-form-area card-body bordered-box bg-white" - :class="{ 'gl-mb-5': shouldShowTokenBody }" + :class="{ 'gl-mb-5': shouldShowTokenBody, 'gl-show-field-errors': hasError }" > <add-issuable-form :show-categorized-issues="showCategorizedIssues" @@ -245,6 +255,8 @@ export default { :auto-complete-epics="autoCompleteEpics" :auto-complete-issues="autoCompleteIssues" :path-id-separator="pathIdSeparator" + :has-error="hasError" + :item-add-failure-message="itemAddFailureMessage" @pendingIssuableRemoveRequest="$emit('pendingIssuableRemoveRequest', $event)" @addIssuableFormInput="$emit('addIssuableFormInput', $event)" @addIssuableFormBlur="$emit('addIssuableFormBlur', $event)" diff --git a/app/assets/javascripts/related_issues/components/related_issues_root.vue b/app/assets/javascripts/related_issues/components/related_issues_root.vue index 38e1d6e9d4f..795eb3b0083 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_root.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_root.vue @@ -107,6 +107,8 @@ export default { isSubmitting: false, isFormVisible: false, inputValue: '', + hasError: false, + errorMessage: null, }; }, computed: { @@ -170,11 +172,11 @@ export default { this.isFormVisible = false; }) .catch(({ response }) => { - let errorMessage = addRelatedIssueErrorMap[this.issuableType]; + this.hasError = true; + this.errorMessage = addRelatedIssueErrorMap[this.issuableType]; if (response && response.data && response.data.message) { - errorMessage = response.data.message; + this.errorMessage = response.data.message; } - createAlert({ message: errorMessage }); }) .finally(() => { this.isSubmitting = false; @@ -266,6 +268,8 @@ export default { :issuable-type="issuableType" :path-id-separator="pathIdSeparator" :show-categorized-issues="showCategorizedIssues" + :has-error="hasError" + :item-add-failure-message="errorMessage" @saveReorder="saveIssueOrder" @toggleAddRelatedIssuesForm="onToggleAddRelatedIssuesForm" @addIssuableFormInput="onInput" diff --git a/app/assets/javascripts/webhooks/components/form_url_app.vue b/app/assets/javascripts/webhooks/components/form_url_app.vue index 5ec16d4ba15..45526ff9080 100644 --- a/app/assets/javascripts/webhooks/components/form_url_app.vue +++ b/app/assets/javascripts/webhooks/components/form_url_app.vue @@ -1,5 +1,5 @@ <script> -import { isEmpty } from 'lodash'; +import { cloneDeep, isEmpty } from 'lodash'; import { GlFormGroup, GlFormInput, GlFormRadio, GlFormRadioGroup, GlLink } from '@gitlab/ui'; import { __, s__ } from '~/locale'; @@ -30,7 +30,7 @@ export default { return { maskEnabled: !isEmpty(this.initialUrlVariables), url: this.initialUrl, - items: isEmpty(this.initialUrlVariables) ? [{}] : this.initialUrlVariables, + items: this.getInitialItems(), }; }, computed: { @@ -54,6 +54,16 @@ export default { }, }, methods: { + getInitialItems() { + return isEmpty(this.initialUrlVariables) ? [{}] : cloneDeep(this.initialUrlVariables); + }, + isEditingItem(key) { + if (isEmpty(this.initialUrlVariables)) { + return false; + } + + return this.initialUrlVariables.some((item) => item.key === key); + }, onItemInput({ index, key, value }) { this.$set(this.items, index, { key, value }); }, @@ -112,6 +122,7 @@ export default { :index="index" :item-key="key" :item-value="value" + :is-editing="isEditingItem(key)" @input="onItemInput" @remove="removeItem" /> diff --git a/app/assets/javascripts/webhooks/components/form_url_mask_item.vue b/app/assets/javascripts/webhooks/components/form_url_mask_item.vue index 3b75f9b6c0d..aa5d9ce57f4 100644 --- a/app/assets/javascripts/webhooks/components/form_url_mask_item.vue +++ b/app/assets/javascripts/webhooks/components/form_url_mask_item.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlFormGroup, GlFormInput } from '@gitlab/ui'; import { s__ } from '~/locale'; +import { MASK_ITEM_VALUE_HIDDEN } from '../constants'; export default { components: { @@ -24,6 +25,11 @@ export default { required: false, default: null, }, + isEditing: { + type: Boolean, + required: false, + default: false, + }, }, computed: { keyInputId() { @@ -32,6 +38,9 @@ export default { valueInputId() { return this.inputId('value'); }, + displayValue() { + return this.isEditing ? MASK_ITEM_VALUE_HIDDEN : this.itemValue; + }, }, methods: { inputId(type) { @@ -68,7 +77,8 @@ export default { <gl-form-input :id="valueInputId" :name="inputName('value')" - :value="itemValue" + :value="displayValue" + :disabled="isEditing" @input="onValueInput" /> </gl-form-group> @@ -82,9 +92,15 @@ export default { :id="keyInputId" :name="inputName('key')" :value="itemKey" + :disabled="isEditing" @input="onKeyInput" /> </gl-form-group> - <gl-button icon="remove" :aria-label="__('Remove')" @click="onRemoveClick" /> + <gl-button + icon="remove" + :aria-label="__('Remove')" + :disabled="isEditing" + @click="onRemoveClick" + /> </div> </template> diff --git a/app/assets/javascripts/webhooks/constants.js b/app/assets/javascripts/webhooks/constants.js index abef16545bc..6710a418117 100644 --- a/app/assets/javascripts/webhooks/constants.js +++ b/app/assets/javascripts/webhooks/constants.js @@ -15,3 +15,5 @@ export const descriptionText = { ), [BRANCH_FILTER_REGEX]: s__('Webhooks|Regex such as %{REGEX_CODE} is supported.'), }; + +export const MASK_ITEM_VALUE_HIDDEN = '************'; diff --git a/app/assets/javascripts/webhooks/index.js b/app/assets/javascripts/webhooks/index.js index 1b2b33e44c1..d90680a9bac 100644 --- a/app/assets/javascripts/webhooks/index.js +++ b/app/assets/javascripts/webhooks/index.js @@ -10,6 +10,11 @@ export default () => { const { url: initialUrl, urlVariables } = el.dataset; + // Convert the array of 'key' strings to array of { key } objects + const initialUrlVariables = urlVariables + ? JSON.parse(urlVariables)?.map((key) => ({ key })) + : undefined; + return new Vue({ el, name: 'WebhookFormRoot', @@ -17,7 +22,7 @@ export default () => { return createElement(FormUrlApp, { props: { initialUrl, - initialUrlVariables: urlVariables ? JSON.parse(urlVariables) : undefined, + initialUrlVariables, }, }); }, diff --git a/app/helpers/hooks_helper.rb b/app/helpers/hooks_helper.rb index e050ccc0e40..44ed61b8fde 100644 --- a/app/helpers/hooks_helper.rb +++ b/app/helpers/hooks_helper.rb @@ -4,7 +4,7 @@ module HooksHelper def webhook_form_data(hook) { url: hook.url, - url_variables: nil + url_variables: Gitlab::Json.dump(hook.url_variables.keys) } end diff --git a/app/services/clusters/applications/patch_service.rb b/app/services/clusters/applications/patch_service.rb deleted file mode 100644 index fbea18bae6b..00000000000 --- a/app/services/clusters/applications/patch_service.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class PatchService < BaseHelmService - def execute - return unless app.scheduled? - - app.make_updating! - - patch - end - - private - - def patch - log_event(:begin_patch) - helm_api.update(update_command) - - log_event(:schedule_wait_for_patch) - ClusterWaitForAppInstallationWorker.perform_in( - ClusterWaitForAppInstallationWorker::INTERVAL, app.name, app.id) - rescue Kubeclient::HttpError => e - log_error(e) - app.make_errored!(_('Kubernetes error: %{error_code}') % { error_code: e.error_code }) - rescue StandardError => e - log_error(e) - app.make_errored!(_('Failed to update.')) - end - end - end -end diff --git a/app/services/clusters/applications/update_service.rb b/app/services/clusters/applications/update_service.rb deleted file mode 100644 index 7a36401f156..00000000000 --- a/app/services/clusters/applications/update_service.rb +++ /dev/null @@ -1,17 +0,0 @@ -# frozen_string_literal: true - -module Clusters - module Applications - class UpdateService < Clusters::Applications::BaseService - private - - def worker_class(application) - ClusterPatchAppWorker - end - - def builder - cluster.public_send(application_class.association_name) # rubocop:disable GitlabSecurity/PublicSend - end - end - end -end diff --git a/app/workers/cluster_patch_app_worker.rb b/app/workers/cluster_patch_app_worker.rb index bb16cf7a5e6..1ef9dc7f6fe 100644 --- a/app/workers/cluster_patch_app_worker.rb +++ b/app/workers/cluster_patch_app_worker.rb @@ -1,5 +1,8 @@ # frozen_string_literal: true +# DEPRECATED +# +# To be removed by https://gitlab.com/gitlab-org/gitlab/-/issues/366573 class ClusterPatchAppWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker @@ -12,9 +15,5 @@ class ClusterPatchAppWorker # rubocop:disable Scalability/IdempotentWorker worker_has_external_dependencies! loggable_arguments 0 - def perform(app_name, app_id) - find_application(app_name, app_id) do |app| - Clusters::Applications::PatchService.new(app).execute - end - end + def perform(app_name, app_id); end end diff --git a/db/post_migrate/20221018232820_add_temp_index_for_user_details_fields.rb b/db/post_migrate/20221018232820_add_temp_index_for_user_details_fields.rb new file mode 100644 index 00000000000..b46b316981d --- /dev/null +++ b/db/post_migrate/20221018232820_add_temp_index_for_user_details_fields.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +class AddTempIndexForUserDetailsFields < Gitlab::Database::Migration[2.0] + INDEX_NAME = 'tmp_idx_where_user_details_fields_filled' + + disable_ddl_transaction! + + def up + add_concurrent_index :users, :id, name: INDEX_NAME, where: <<~QUERY + (COALESCE(linkedin, '') IS DISTINCT FROM '') + OR (COALESCE(twitter, '') IS DISTINCT FROM '') + OR (COALESCE(skype, '') IS DISTINCT FROM '') + OR (COALESCE(website_url, '') IS DISTINCT FROM '') + OR (COALESCE(location, '') IS DISTINCT FROM '') + OR (COALESCE(organization, '') IS DISTINCT FROM '') + QUERY + end + + def down + remove_concurrent_index_by_name :users, INDEX_NAME + end +end diff --git a/db/post_migrate/20221019002459_queue_backfill_user_details_fields.rb b/db/post_migrate/20221019002459_queue_backfill_user_details_fields.rb new file mode 100644 index 00000000000..8ed4416a98d --- /dev/null +++ b/db/post_migrate/20221019002459_queue_backfill_user_details_fields.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +class QueueBackfillUserDetailsFields < Gitlab::Database::Migration[2.0] + MIGRATION = 'BackfillUserDetailsFields' + INTERVAL = 2.minutes + + restrict_gitlab_migration gitlab_schema: :gitlab_main + + def up + queue_batched_background_migration(MIGRATION, :users, :id, job_interval: INTERVAL) + end + + def down + delete_batched_background_migration(MIGRATION, :users, :id, []) + end +end diff --git a/db/schema_migrations/20221018232820 b/db/schema_migrations/20221018232820 new file mode 100644 index 00000000000..870de8adb4a --- /dev/null +++ b/db/schema_migrations/20221018232820 @@ -0,0 +1 @@ +cdf3e65f07f700617f47435b79743b4b35307f47cf46a9696350e55af1774d42
\ No newline at end of file diff --git a/db/schema_migrations/20221019002459 b/db/schema_migrations/20221019002459 new file mode 100644 index 00000000000..cab21003736 --- /dev/null +++ b/db/schema_migrations/20221019002459 @@ -0,0 +1 @@ +6c3fe5bf01ac9e74f142ddb3e093867b62cf430f24ba885f8475ccf7f73899cb
\ No newline at end of file diff --git a/db/structure.sql b/db/structure.sql index e23b88fe4fd..c06dedf9115 100644 --- a/db/structure.sql +++ b/db/structure.sql @@ -31130,6 +31130,8 @@ CREATE INDEX tmp_idx_project_features_on_releases_al_and_repo_al_partial ON proj CREATE INDEX tmp_idx_vulnerabilities_on_id_where_report_type_7_99 ON vulnerabilities USING btree (id) WHERE (report_type = ANY (ARRAY[7, 99])); +CREATE INDEX tmp_idx_where_user_details_fields_filled ON users USING btree (id) WHERE (((COALESCE(linkedin, ''::character varying))::text IS DISTINCT FROM ''::text) OR ((COALESCE(twitter, ''::character varying))::text IS DISTINCT FROM ''::text) OR ((COALESCE(skype, ''::character varying))::text IS DISTINCT FROM ''::text) OR ((COALESCE(website_url, ''::character varying))::text IS DISTINCT FROM ''::text) OR ((COALESCE(location, ''::character varying))::text IS DISTINCT FROM ''::text) OR ((COALESCE(organization, ''::character varying))::text IS DISTINCT FROM ''::text)); + CREATE INDEX tmp_index_ci_job_artifacts_on_expire_at_where_locked_unknown ON ci_job_artifacts USING btree (expire_at, job_id) WHERE ((locked = 2) AND (expire_at IS NOT NULL)); CREATE INDEX tmp_index_ci_job_artifacts_on_id_expire_at_file_type_trace ON ci_job_artifacts USING btree (id) WHERE (((date_part('day'::text, timezone('UTC'::text, expire_at)) = ANY (ARRAY[(21)::double precision, (22)::double precision, (23)::double precision])) AND (date_part('minute'::text, timezone('UTC'::text, expire_at)) = ANY (ARRAY[(0)::double precision, (30)::double precision, (45)::double precision])) AND (date_part('second'::text, timezone('UTC'::text, expire_at)) = (0)::double precision)) OR (file_type = 3)); diff --git a/doc/.markdownlint/require_helper.js b/doc/.markdownlint/require_helper.js new file mode 100644 index 00000000000..7d06cf67419 --- /dev/null +++ b/doc/.markdownlint/require_helper.js @@ -0,0 +1,14 @@ +/** + * Look up the global node modules directory. + * + * Because we install markdownlint packages globally + * in the Docker image where this runs, we need to + * provide the path to the global install location + * when referencing global functions from our own node + * modules. + * + * Image: + * https://gitlab.com/gitlab-org/gitlab-docs/-/blob/main/dockerfiles/gitlab-docs-lint-markdown.Dockerfile + */ +const { execSync } = require('child_process'); +module.exports.globalPath = execSync('yarn global dir').toString().trim() + '/node_modules/'; diff --git a/doc/.markdownlint/rules/tabs_blank_lines.js b/doc/.markdownlint/rules/tabs_blank_lines.js new file mode 100644 index 00000000000..e0e2c1a0a9b --- /dev/null +++ b/doc/.markdownlint/rules/tabs_blank_lines.js @@ -0,0 +1,26 @@ +const { globalPath } = require('../require_helper'); +const { + forEachLine, + getLineMetadata, + isBlankLine, +} = require(`${globalPath}/markdownlint-rule-helpers`); + +module.exports = { + names: ['tabs-blank-lines'], + description: 'Tab elements must be surrounded by blank lines', + tags: ['gitlab-docs', 'tabs'], + function: (params, onError) => { + const tabElements = ['::Tabs', '::EndTabs', ':::TabTitle']; + forEachLine(getLineMetadata(params), (line, lineIndex) => { + const lineHasTab = tabElements.includes(line.split(' ')[0]); + const prevLine = params.lines[lineIndex - 1]; + const nextLine = params.lines[lineIndex + 1]; + + if (lineHasTab && (!isBlankLine(prevLine) || !isBlankLine(nextLine))) { + onError({ + lineNumber: lineIndex + 1, + }); + } + }); + }, +}; diff --git a/doc/.markdownlint/rules/tabs_title_markup.js b/doc/.markdownlint/rules/tabs_title_markup.js new file mode 100644 index 00000000000..9c1de1e630d --- /dev/null +++ b/doc/.markdownlint/rules/tabs_title_markup.js @@ -0,0 +1,31 @@ +const { globalPath } = require('../require_helper'); +const { forEachLine, getLineMetadata } = require(`${globalPath}/markdownlint-rule-helpers`); + +module.exports = { + names: ['tabs-title-markup'], + description: 'Incorrect number of colon characters for tag', + information: new URL('https://docs.gitlab.com/ee/development/documentation/styleguide/#tabs'), + tags: ['gitlab-docs', 'tabs'], + function: (params, onError) => { + // Note the correct number of colons in each tab tag type. + const wrapperColons = 2; + const titleColons = 3; + + forEachLine(getLineMetadata(params), (line, lineIndex) => { + // Get the number of colons in this line. + const colonCount = [...line].filter((x) => x === ':').length; + + // Throw an error in the case of a mismatch. + if ( + ((line.includes(':Tabs') || line.includes(':EndTabs')) && colonCount !== wrapperColons) || + (line.includes(':TabTitle') && colonCount !== titleColons) + ) { + const correctColonCount = line.includes(':TabTitle') ? wrapperColons : titleColons; + onError({ + lineNumber: lineIndex + 1, + detail: `Actual: ${colonCount}; Expected: ${correctColonCount}`, + }); + } + }); + }, +}; diff --git a/doc/.markdownlint/rules/tabs_title_text.js b/doc/.markdownlint/rules/tabs_title_text.js new file mode 100644 index 00000000000..672aa70f562 --- /dev/null +++ b/doc/.markdownlint/rules/tabs_title_text.js @@ -0,0 +1,23 @@ +const { globalPath } = require('../require_helper'); +const { + forEachLine, + getLineMetadata, + isBlankLine, +} = require(`${globalPath}/markdownlint-rule-helpers`); + +module.exports = { + names: ['tabs-title-text'], + description: 'Tab without title text', + information: new URL('https://docs.gitlab.com/ee/development/documentation/styleguide/#tabs'), + tags: ['gitlab-docs', 'tabs'], + function: (params, onError) => { + forEachLine(getLineMetadata(params), (line, lineIndex) => { + if (!isBlankLine(line) && line.replace(':::TabTitle', '').trim() === '') { + onError({ + lineNumber: lineIndex + 1, + detail: 'Expected: :::TabTitle <your title here>; Actual: :::TabTitle', + }); + } + }); + }, +}; diff --git a/doc/.markdownlint/rules/tabs_wrapper_tags.js b/doc/.markdownlint/rules/tabs_wrapper_tags.js new file mode 100644 index 00000000000..beacec0b737 --- /dev/null +++ b/doc/.markdownlint/rules/tabs_wrapper_tags.js @@ -0,0 +1,21 @@ +module.exports = { + names: ['tabs-wrapper-tags'], + description: 'Unequal number of tab start and end tags', + information: new URL('https://docs.gitlab.com/ee/development/documentation/styleguide/#tabs'), + tags: ['gitlab-docs', 'tabs'], + function: function rule(params, onError) { + const tabStarts = params.lines.filter((line) => line === '::Tabs'); + const tabEnds = params.lines.filter((line) => line === '::EndTabs'); + + if (tabStarts.length !== tabEnds.length) { + const errorIndex = + params.lines.indexOf('::Tabs') > 0 + ? params.lines.indexOf('::Tabs') + : params.lines.indexOf('::EndTabs'); + onError({ + lineNumber: errorIndex + 1, + detail: `Opening tags: ${tabStarts.length}; Closing tags: ${tabEnds.length}`, + }); + } + }, +}; diff --git a/lib/api/entities/pull_mirror.rb b/lib/api/entities/pull_mirror.rb index 6914a79b18e..72a5220987e 100644 --- a/lib/api/entities/pull_mirror.rb +++ b/lib/api/entities/pull_mirror.rb @@ -3,15 +3,17 @@ module API module Entities class PullMirror < Grape::Entity - expose :id - expose :status, as: :update_status - expose :url do |import_state| + expose :id, documentation: { type: 'integer', example: 101486 } + expose :status, as: :update_status, documentation: { type: 'string', example: 'finished' } + expose :url, +documentation: { type: 'string', + example: 'https://*****:*****@gitlab.com/gitlab-org/security/gitlab.git' } do |import_state| import_state.project.safe_import_url end - expose :last_error - expose :last_update_at - expose :last_update_started_at - expose :last_successful_update_at + expose :last_error, documentation: { type: 'string', example: nil } + expose :last_update_at, documentation: { type: 'dateTime', example: '2020-01-06T17:32:02.823Z' } + expose :last_update_started_at, documentation: { type: 'dateTime', example: '2020-01-06T17:32:02.823Z' } + expose :last_successful_update_at, documentation: { type: 'dateTime', example: '2020-01-06T17:32:02.823Z' } end end end diff --git a/lib/gitlab/background_migration/backfill_user_details_fields.rb b/lib/gitlab/background_migration/backfill_user_details_fields.rb new file mode 100644 index 00000000000..8d8619256b0 --- /dev/null +++ b/lib/gitlab/background_migration/backfill_user_details_fields.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +module Gitlab + module BackgroundMigration + # Class that will backfill the following fields from user to user_details + # * linkedin + # * twitter + # * skype + # * website_url + # * location + # * organization + class BackfillUserDetailsFields < BatchedMigrationJob + operation_name :backfill_user_details_fields + + def perform + query = <<~SQL + (COALESCE(linkedin, '') IS DISTINCT FROM '') + OR (COALESCE(twitter, '') IS DISTINCT FROM '') + OR (COALESCE(skype, '') IS DISTINCT FROM '') + OR (COALESCE(website_url, '') IS DISTINCT FROM '') + OR (COALESCE(location, '') IS DISTINCT FROM '') + OR (COALESCE(organization, '') IS DISTINCT FROM '') + SQL + field_limit = UserDetail::DEFAULT_FIELD_LENGTH + + each_sub_batch( + batching_scope: ->(relation) { + relation.where(query).select( + 'id AS user_id', + "substring(COALESCE(linkedin, '') from 1 for #{field_limit}) AS linkedin", + "substring(COALESCE(twitter, '') from 1 for #{field_limit}) AS twitter", + "substring(COALESCE(skype, '') from 1 for #{field_limit}) AS skype", + "substring(COALESCE(website_url, '') from 1 for #{field_limit}) AS website_url", + "substring(COALESCE(location, '') from 1 for #{field_limit}) AS location", + "substring(COALESCE(organization, '') from 1 for #{field_limit}) AS organization" + ) + } + ) do |sub_batch| + upsert_user_details_fields(sub_batch) + end + end + + def upsert_user_details_fields(relation) + connection.execute( + <<~SQL + INSERT INTO user_details (user_id, linkedin, twitter, skype, website_url, location, organization) + #{relation.to_sql} + ON CONFLICT (user_id) + DO UPDATE SET + "linkedin" = EXCLUDED."linkedin", + "twitter" = EXCLUDED."twitter", + "skype" = EXCLUDED."skype", + "website_url" = EXCLUDED."website_url", + "location" = EXCLUDED."location", + "organization" = EXCLUDED."organization" + SQL + ) + end + end + end +end diff --git a/lib/sidebars/projects/menus/deployments_menu.rb b/lib/sidebars/projects/menus/deployments_menu.rb index 24e58e71023..9904d533f47 100644 --- a/lib/sidebars/projects/menus/deployments_menu.rb +++ b/lib/sidebars/projects/menus/deployments_menu.rb @@ -6,8 +6,8 @@ module Sidebars class DeploymentsMenu < ::Sidebars::Menu override :configure_menu_items def configure_menu_items - add_item(feature_flags_menu_item) add_item(environments_menu_item) + add_item(feature_flags_menu_item) add_item(releases_menu_item) true diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 132e6b1080d..fcfb4f8ef26 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -16615,9 +16615,6 @@ msgstr "" msgid "Failed to update the Canary Ingress." msgstr "" -msgid "Failed to update." -msgstr "" - msgid "Failed to upgrade." msgstr "" @@ -48761,6 +48758,9 @@ msgstr "" msgid "must be unique by status and elapsed time within a policy" msgstr "" +msgid "must belong to same project of the work item." +msgstr "" + msgid "must have a repository" msgstr "" diff --git a/package.json b/package.json index 826c7ca9676..100c111fb0b 100644 --- a/package.json +++ b/package.json @@ -54,7 +54,7 @@ "@gitlab/at.js": "1.5.7", "@gitlab/favicon-overlay": "2.0.0", "@gitlab/svgs": "3.5.0", - "@gitlab/ui": "49.2.0", + "@gitlab/ui": "49.2.1", "@gitlab/visual-review-tools": "1.7.3", "@gitlab/web-ide": "0.0.1-dev-20220815034418", "@rails/actioncable": "6.1.4-7", diff --git a/scripts/lint-doc.sh b/scripts/lint-doc.sh index f954b2d8106..68dfac95ef6 100755 --- a/scripts/lint-doc.sh +++ b/scripts/lint-doc.sh @@ -151,7 +151,7 @@ if [ -z "${MD_DOC_PATH}" ] then echo "Merged results pipeline detected, but no markdown files found. Skipping." else - run_locally_or_in_docker 'markdownlint' "--config .markdownlint.yml ${MD_DOC_PATH}" + run_locally_or_in_docker 'markdownlint' "--config .markdownlint.yml ${MD_DOC_PATH} --rules doc/.markdownlint/rules" fi echo '=> Linting prose...' diff --git a/spec/factories/project_hooks.rb b/spec/factories/project_hooks.rb index dbb5c357acb..946b3925ee9 100644 --- a/spec/factories/project_hooks.rb +++ b/spec/factories/project_hooks.rb @@ -6,6 +6,10 @@ FactoryBot.define do enable_ssl_verification { false } project + trait :url_variables do + url_variables { { 'abc' => 'supers3cret' } } + end + trait :token do token { generate(:token) } end diff --git a/spec/frontend/behaviors/markdown/render_sandboxed_mermaid_spec.js b/spec/frontend/behaviors/markdown/render_sandboxed_mermaid_spec.js index de0e5063e49..2b9442162aa 100644 --- a/spec/frontend/behaviors/markdown/render_sandboxed_mermaid_spec.js +++ b/spec/frontend/behaviors/markdown/render_sandboxed_mermaid_spec.js @@ -1,127 +1,34 @@ -import { createWrapper } from '@vue/test-utils'; -import { __ } from '~/locale'; +import $ from 'jquery'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import renderMermaid, { - MAX_CHAR_LIMIT, - MAX_MERMAID_BLOCK_LIMIT, - LAZY_ALERT_SHOWN_CLASS, -} from '~/behaviors/markdown/render_sandboxed_mermaid'; +import renderMermaid from '~/behaviors/markdown/render_sandboxed_mermaid'; -describe('Mermaid diagrams renderer', () => { - // Finders - const findMermaidIframes = () => document.querySelectorAll('iframe[src="/-/sandbox/mermaid"]'); - const findDangerousMermaidAlert = () => - createWrapper(document.querySelector('[data-testid="alert-warning"]')); +describe('Render mermaid diagrams for Gitlab Flavoured Markdown', () => { + it('Does something', () => { + document.body.dataset.page = ''; + setHTMLFixture(` + <div class="gl-relative markdown-code-block js-markdown-code"> + <pre data-sourcepos="1:1-7:3" class="code highlight js-syntax-highlight language-mermaid white" lang="mermaid" id="code-4"> + <code class="js-render-mermaid"> + <span id="LC1" class="line" lang="mermaid">graph TD;</span> + <span id="LC2" class="line" lang="mermaid">A-->B</span> + <span id="LC3" class="line" lang="mermaid">A-->C</span> + <span id="LC4" class="line" lang="mermaid">B-->D</span> + <span id="LC5" class="line" lang="mermaid">C-->D</span> + </code> + </pre> + <copy-code> + <button type="button" class="btn btn-default btn-md gl-button btn-icon has-tooltip" data-title="Copy to clipboard" data-clipboard-target="pre#code-4"> + <svg><use xlink:href="/assets/icons-7f1680a3670112fe4c8ef57b9dfb93f0f61b43a2a479d7abd6c83bcb724b9201.svg#copy-to-clipboard"></use></svg> + </button> + </copy-code> + </div>`); + const els = $('pre.js-syntax-highlight').find('.js-render-mermaid'); + + renderMermaid(els); - // Helpers - const renderDiagrams = () => { - renderMermaid([...document.querySelectorAll('.js-render-mermaid')]); jest.runAllTimers(); - }; - - beforeEach(() => { - document.body.dataset.page = ''; - }); + expect(document.querySelector('pre.js-syntax-highlight').classList).toContain('gl-sr-only'); - afterEach(() => { resetHTMLFixture(); }); - - it('renders a mermaid diagram', () => { - setHTMLFixture('<pre><code class="js-render-mermaid"></code></pre>'); - - expect(findMermaidIframes()).toHaveLength(0); - - renderDiagrams(); - - expect(document.querySelector('pre').classList).toContain('gl-sr-only'); - expect(findMermaidIframes()).toHaveLength(1); - }); - - describe('within a details element', () => { - beforeEach(() => { - setHTMLFixture('<details><pre><code class="js-render-mermaid"></code></pre></details>'); - renderDiagrams(); - }); - - it('does not render the diagram on load', () => { - expect(findMermaidIframes()).toHaveLength(0); - }); - - it('render the diagram when the details element is opened', () => { - document.querySelector('details').setAttribute('open', true); - document.querySelector('details').dispatchEvent(new Event('toggle')); - jest.runAllTimers(); - - expect(findMermaidIframes()).toHaveLength(1); - }); - }); - - describe('dangerous diagrams', () => { - describe(`when the diagram's source exceeds ${MAX_CHAR_LIMIT} characters`, () => { - beforeEach(() => { - setHTMLFixture( - `<pre> - <code class="js-render-mermaid">${Array(MAX_CHAR_LIMIT + 1) - .fill('a') - .join('')}</code> - </pre>`, - ); - renderDiagrams(); - }); - it('does not render the diagram on load', () => { - expect(findMermaidIframes()).toHaveLength(0); - }); - - it('shows a warning about performance impact when rendering the diagram', () => { - expect(document.querySelector('pre').classList).toContain(LAZY_ALERT_SHOWN_CLASS); - expect(findDangerousMermaidAlert().exists()).toBe(true); - expect(findDangerousMermaidAlert().text()).toContain( - __('Warning: Displaying this diagram might cause performance issues on this page.'), - ); - }); - - it("renders the diagram when clicking on the alert's button", () => { - findDangerousMermaidAlert().find('button').trigger('click'); - jest.runAllTimers(); - - expect(findMermaidIframes()).toHaveLength(1); - }); - }); - - it(`stops rendering diagrams once the total rendered source exceeds ${MAX_CHAR_LIMIT} characters`, () => { - setHTMLFixture( - `<pre> - <code class="js-render-mermaid">${Array(MAX_CHAR_LIMIT - 1) - .fill('a') - .join('')}</code> - <code class="js-render-mermaid">2</code> - <code class="js-render-mermaid">3</code> - <code class="js-render-mermaid">4</code> - </pre>`, - ); - renderDiagrams(); - - expect(findMermaidIframes()).toHaveLength(3); - }); - - // Note: The test case below is provided for convenience but should remain skipped as the DOM - // operations it requires are too expensive and would significantly slow down the test suite. - // eslint-disable-next-line jest/no-disabled-tests - it.skip(`stops rendering diagrams when the rendered diagrams count exceeds ${MAX_MERMAID_BLOCK_LIMIT}`, () => { - setHTMLFixture( - `<pre> - ${Array(MAX_MERMAID_BLOCK_LIMIT + 1) - .fill('<code class="js-render-mermaid"></code>') - .join('')} - </pre>`, - ); - renderDiagrams(); - - expect([...document.querySelectorAll('.js-render-mermaid')]).toHaveLength( - MAX_MERMAID_BLOCK_LIMIT + 1, - ); - expect(findMermaidIframes()).toHaveLength(MAX_MERMAID_BLOCK_LIMIT); - }); - }); }); diff --git a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js index 680dbd68493..bedf8bcaf34 100644 --- a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js +++ b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js @@ -201,18 +201,20 @@ describe('RelatedIssuesRoot', () => { ]); }); - it('displays a message from the backend upon error', async () => { + it('passes an error message from the backend upon error', async () => { const input = '#123'; const message = 'error'; mock.onPost(defaultProps.endpoint).reply(409, { message }); wrapper.vm.store.setPendingReferences([issuable1.reference, issuable2.reference]); - expect(createAlert).not.toHaveBeenCalled(); + expect(findRelatedIssuesBlock().props('hasError')).toBe(false); + expect(findRelatedIssuesBlock().props('itemAddFailureMessage')).toBe(null); findRelatedIssuesBlock().vm.$emit('addIssuableFormSubmit', input); await waitForPromises(); - expect(createAlert).toHaveBeenCalledWith({ message }); + expect(findRelatedIssuesBlock().props('hasError')).toBe(true); + expect(findRelatedIssuesBlock().props('itemAddFailureMessage')).toBe(message); }); }); diff --git a/spec/frontend/webhooks/components/form_url_app_spec.js b/spec/frontend/webhooks/components/form_url_app_spec.js index 16e0a3f549e..76812097748 100644 --- a/spec/frontend/webhooks/components/form_url_app_spec.js +++ b/spec/frontend/webhooks/components/form_url_app_spec.js @@ -60,8 +60,10 @@ describe('FormUrlApp', () => { expect(findAllUrlMaskItems()).toHaveLength(1); const firstItem = findAllUrlMaskItems().at(0); - expect(firstItem.props('itemKey')).toBeNull(); - expect(firstItem.props('itemValue')).toBeNull(); + expect(firstItem.props()).toMatchObject({ + itemKey: null, + itemValue: null, + }); }); }); @@ -90,12 +92,18 @@ describe('FormUrlApp', () => { expect(findAllUrlMaskItems()).toHaveLength(2); const firstItem = findAllUrlMaskItems().at(0); - expect(firstItem.props('itemKey')).toBe(mockItem1.key); - expect(firstItem.props('itemValue')).toBe(mockItem1.value); + expect(firstItem.props()).toMatchObject({ + itemKey: mockItem1.key, + itemValue: mockItem1.value, + isEditing: true, + }); const secondItem = findAllUrlMaskItems().at(1); - expect(secondItem.props('itemKey')).toBe(mockItem2.key); - expect(secondItem.props('itemValue')).toBe(mockItem2.value); + expect(secondItem.props()).toMatchObject({ + itemKey: mockItem2.key, + itemValue: mockItem2.value, + isEditing: true, + }); }); describe('on mask item input', () => { @@ -106,8 +114,10 @@ describe('FormUrlApp', () => { firstItem.vm.$emit('input', mockInput); await nextTick(); - expect(firstItem.props('itemKey')).toBe(mockInput.key); - expect(firstItem.props('itemValue')).toBe(mockInput.value); + expect(firstItem.props()).toMatchObject({ + itemKey: mockInput.key, + itemValue: mockInput.value, + }); }); }); @@ -119,8 +129,10 @@ describe('FormUrlApp', () => { expect(findAllUrlMaskItems()).toHaveLength(3); const lastItem = findAllUrlMaskItems().at(-1); - expect(lastItem.props('itemKey')).toBeNull(); - expect(lastItem.props('itemValue')).toBeNull(); + expect(lastItem.props()).toMatchObject({ + itemKey: null, + itemValue: null, + }); }); }); @@ -133,8 +145,10 @@ describe('FormUrlApp', () => { expect(findAllUrlMaskItems()).toHaveLength(1); const newFirstItem = findAllUrlMaskItems().at(0); - expect(newFirstItem.props('itemKey')).toBe(mockItem2.key); - expect(newFirstItem.props('itemValue')).toBe(mockItem2.value); + expect(newFirstItem.props()).toMatchObject({ + itemKey: mockItem2.key, + itemValue: mockItem2.value, + }); }); }); }); diff --git a/spec/frontend/webhooks/components/form_url_mask_item_spec.js b/spec/frontend/webhooks/components/form_url_mask_item_spec.js index ab028ef2997..a6323928d22 100644 --- a/spec/frontend/webhooks/components/form_url_mask_item_spec.js +++ b/spec/frontend/webhooks/components/form_url_mask_item_spec.js @@ -31,19 +31,42 @@ describe('FormUrlMaskItem', () => { describe('template', () => { it('renders input for key and value', () => { - createComponent(); + createComponent({ props: { itemKey: mockKey, itemValue: mockValue } }); const keyInput = findMaskItemKey(); expect(keyInput.attributes('label')).toBe(FormUrlMaskItem.i18n.keyLabel); - expect(keyInput.findComponent(GlFormInput).attributes('name')).toBe( - 'hook[url_variables][][key]', - ); + expect(keyInput.findComponent(GlFormInput).attributes()).toMatchObject({ + name: 'hook[url_variables][][key]', + value: mockKey, + }); const valueInput = findMaskItemValue(); expect(valueInput.attributes('label')).toBe(FormUrlMaskItem.i18n.valueLabel); - expect(valueInput.findComponent(GlFormInput).attributes('name')).toBe( - 'hook[url_variables][][value]', - ); + expect(valueInput.findComponent(GlFormInput).attributes()).toMatchObject({ + name: 'hook[url_variables][][value]', + value: mockValue, + }); + }); + + describe('when isEditing is true', () => { + beforeEach(() => { + createComponent({ props: { isEditing: true } }); + }); + + it('renders disabled key and value', () => { + expect(findMaskItemKey().findComponent(GlFormInput).attributes('disabled')).toBe('true'); + expect(findMaskItemValue().findComponent(GlFormInput).attributes('disabled')).toBe('true'); + }); + + it('renders disabled remove button', () => { + expect(findRemoveButton().attributes('disabled')).toBe('true'); + }); + + it('displays ************ as input value', () => { + expect(findMaskItemValue().findComponent(GlFormInput).attributes('value')).toBe( + '************', + ); + }); }); describe('on key input', () => { diff --git a/spec/helpers/hooks_helper_spec.rb b/spec/helpers/hooks_helper_spec.rb index 8f438a3ddc8..28f6322466f 100644 --- a/spec/helpers/hooks_helper_spec.rb +++ b/spec/helpers/hooks_helper_spec.rb @@ -3,16 +3,33 @@ require 'spec_helper' RSpec.describe HooksHelper do - let(:project) { create(:project) } - let(:project_hook) { create(:project_hook, project: project) } - let(:service_hook) { create(:service_hook, integration: create(:drone_ci_integration)) } - let(:system_hook) { create(:system_hook) } + let(:project) { build_stubbed(:project) } + let(:project_hook) { build_stubbed(:project_hook, project: project) } + let(:service_hook) { build_stubbed(:service_hook, integration: build_stubbed(:drone_ci_integration)) } + let(:system_hook) { build_stubbed(:system_hook) } describe '#webhook_form_data' do subject { helper.webhook_form_data(project_hook) } - it { expect(subject[:url]).to eq(project_hook.url) } - it { expect(subject[:url_variables]).to be_nil } + context 'when there are no URL variables' do + it 'returns proper data' do + expect(subject).to match( + url: project_hook.url, + url_variables: Gitlab::Json.dump([]) + ) + end + end + + context 'when there are URL variables' do + let(:project_hook) { build_stubbed(:project_hook, :url_variables, project: project) } + + it 'returns proper data' do + expect(subject).to match( + url: project_hook.url, + url_variables: Gitlab::Json.dump(['abc']) + ) + end + end end describe '#link_to_test_hook' do @@ -31,7 +48,7 @@ RSpec.describe HooksHelper do describe '#hook_log_path' do context 'with a project hook' do - let(:web_hook_log) { create(:web_hook_log, web_hook: project_hook) } + let(:web_hook_log) { build_stubbed(:web_hook_log, web_hook: project_hook) } it 'returns project-namespaced link' do expect(helper.hook_log_path(project_hook, web_hook_log)) @@ -40,7 +57,7 @@ RSpec.describe HooksHelper do end context 'with a service hook' do - let(:web_hook_log) { create(:web_hook_log, web_hook: service_hook) } + let(:web_hook_log) { build_stubbed(:web_hook_log, web_hook: service_hook) } it 'returns project-namespaced link' do expect(helper.hook_log_path(project_hook, web_hook_log)) @@ -49,7 +66,7 @@ RSpec.describe HooksHelper do end context 'with a system hook' do - let(:web_hook_log) { create(:web_hook_log, web_hook: system_hook) } + let(:web_hook_log) { build_stubbed(:web_hook_log, web_hook: system_hook) } it 'returns admin-namespaced link' do expect(helper.hook_log_path(system_hook, web_hook_log)) diff --git a/spec/lib/gitlab/background_migration/backfill_user_details_fields_spec.rb b/spec/lib/gitlab/background_migration/backfill_user_details_fields_spec.rb new file mode 100644 index 00000000000..04ada1703bc --- /dev/null +++ b/spec/lib/gitlab/background_migration/backfill_user_details_fields_spec.rb @@ -0,0 +1,222 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::BackgroundMigration::BackfillUserDetailsFields, :migration, schema: 20221018232820 do + let(:users) { table(:users) } + let(:user_details) { table(:user_details) } + + let!(:user_all_fields_backfill) do + users.create!( + name: generate(:name), + email: generate(:email), + projects_limit: 1, + linkedin: 'linked-in', + twitter: '@twitter', + skype: 'skype', + website_url: 'https://example.com', + location: 'Antarctica', + organization: 'Gitlab' + ) + end + + let!(:user_long_details_fields) do + length = UserDetail::DEFAULT_FIELD_LENGTH + 1 + users.create!( + name: generate(:name), + email: generate(:email), + projects_limit: 1, + linkedin: 'l' * length, + twitter: 't' * length, + skype: 's' * length, + website_url: "https://#{'a' * (length - 12)}.com", + location: 'l' * length, + organization: 'o' * length + ) + end + + let!(:user_nil_details_fields) do + users.create!( + name: generate(:name), + email: generate(:email), + projects_limit: 1 + ) + end + + let!(:user_empty_details_fields) do + users.create!( + name: generate(:name), + email: generate(:email), + projects_limit: 1, + linkedin: '', + twitter: '', + skype: '', + website_url: '', + location: '', + organization: '' + ) + end + + let!(:user_with_bio) do + users.create!( + name: generate(:name), + email: generate(:email), + projects_limit: 1, + linkedin: 'linked-in', + twitter: '@twitter', + skype: 'skype', + website_url: 'https://example.com', + location: 'Antarctica', + organization: 'Gitlab' + ) + end + + let!(:bio_user_details) do + user_details + .find_or_create_by!(user_id: user_with_bio.id) + .update!(bio: 'bio') + end + + let!(:user_with_details) do + users.create!( + name: generate(:name), + email: generate(:email), + projects_limit: 1, + linkedin: 'linked-in', + twitter: '@twitter', + skype: 'skype', + website_url: 'https://example.com', + location: 'Antarctica', + organization: 'Gitlab' + ) + end + + let!(:existing_user_details) do + user_details + .find_or_create_by!(user_id: user_with_details.id) + .update!( + linkedin: 'linked-in', + twitter: '@twitter', + skype: 'skype', + website_url: 'https://example.com', + location: 'Antarctica', + organization: 'Gitlab' + ) + end + + let!(:user_different_details) do + users.create!( + name: generate(:name), + email: generate(:email), + projects_limit: 1, + linkedin: 'linked-in', + twitter: '@twitter', + skype: 'skype', + website_url: 'https://example.com', + location: 'Antarctica', + organization: 'Gitlab' + ) + end + + let!(:differing_details) do + user_details + .find_or_create_by!(user_id: user_different_details.id) + .update!( + linkedin: 'details-in', + twitter: '@details', + skype: 'details_skype', + website_url: 'https://details.site', + location: 'Details Location', + organization: 'Details Organization' + ) + end + + let(:user_ids) do + [ + user_all_fields_backfill, + user_long_details_fields, + user_nil_details_fields, + user_empty_details_fields, + user_with_bio, + user_with_details, + user_different_details + ].map(&:id) + end + + subject do + described_class.new( + start_id: user_ids.min, + end_id: user_ids.max, + batch_table: 'users', + batch_column: 'id', + sub_batch_size: 1_000, + pause_ms: 0, + connection: ApplicationRecord.connection + ) + end + + it 'processes all relevant records' do + expect { subject.perform }.to change { user_details.all.size }.to(5) + end + + it 'backfills new user_details fields' do + subject.perform + + user_detail = user_details.find_by!(user_id: user_all_fields_backfill.id) + expect(user_detail.linkedin).to eq('linked-in') + expect(user_detail.twitter).to eq('@twitter') + expect(user_detail.skype).to eq('skype') + expect(user_detail.website_url).to eq('https://example.com') + expect(user_detail.location).to eq('Antarctica') + expect(user_detail.organization).to eq('Gitlab') + end + + it 'does not migrate nil fields' do + subject.perform + + expect(user_details.find_by(user_id: user_nil_details_fields)).to be_nil + end + + it 'does not migrate empty fields' do + subject.perform + + expect(user_details.find_by(user_id: user_empty_details_fields)).to be_nil + end + + it 'backfills new fields without overwriting existing `bio` field' do + subject.perform + + user_detail = user_details.find_by!(user_id: user_with_bio.id) + expect(user_detail.bio).to eq('bio') + expect(user_detail.linkedin).to eq('linked-in') + expect(user_detail.twitter).to eq('@twitter') + expect(user_detail.skype).to eq('skype') + expect(user_detail.website_url).to eq('https://example.com') + expect(user_detail.location).to eq('Antarctica') + expect(user_detail.organization).to eq('Gitlab') + end + + context 'when user details are unchanged' do + it 'does not change existing details' do + expect { subject.perform }.not_to change { + user_details.find_by!(user_id: user_with_details.id).attributes + } + end + end + + context 'when user details are changed' do + it 'updates existing user details' do + expect { subject.perform }.to change { + user_details.find_by!(user_id: user_different_details.id).attributes + } + + user_detail = user_details.find_by!(user_id: user_different_details.id) + expect(user_detail.linkedin).to eq('linked-in') + expect(user_detail.twitter).to eq('@twitter') + expect(user_detail.skype).to eq('skype') + expect(user_detail.website_url).to eq('https://example.com') + expect(user_detail.location).to eq('Antarctica') + expect(user_detail.organization).to eq('Gitlab') + end + end +end diff --git a/spec/migrations/queue_backfill_user_details_fields_spec.rb b/spec/migrations/queue_backfill_user_details_fields_spec.rb new file mode 100644 index 00000000000..388ac6d1bce --- /dev/null +++ b/spec/migrations/queue_backfill_user_details_fields_spec.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' +require_migration! + +RSpec.describe QueueBackfillUserDetailsFields do + let_it_be(:batched_migration) { described_class::MIGRATION } + + it 'schedules a new batched migration' do + reversible_migration do |migration| + migration.before -> { + expect(batched_migration).not_to have_scheduled_batched_migration + } + + migration.after -> { + expect(batched_migration).to have_scheduled_batched_migration( + table_name: :users, + column_name: :id, + interval: described_class::INTERVAL + ) + } + end + end +end diff --git a/spec/services/clusters/applications/patch_service_spec.rb b/spec/services/clusters/applications/patch_service_spec.rb deleted file mode 100644 index 281da62b80b..00000000000 --- a/spec/services/clusters/applications/patch_service_spec.rb +++ /dev/null @@ -1,80 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Clusters::Applications::PatchService do - describe '#execute' do - let(:application) { create(:clusters_applications_knative, :scheduled) } - let!(:update_command) { application.update_command } - let(:service) { described_class.new(application) } - let(:helm_client) { instance_double(Gitlab::Kubernetes::Helm::API) } - - before do - allow(service).to receive(:update_command).and_return(update_command) - allow(service).to receive(:helm_api).and_return(helm_client) - end - - context 'when there are no errors' do - before do - expect(helm_client).to receive(:update).with(update_command) - allow(ClusterWaitForAppInstallationWorker).to receive(:perform_in).and_return(nil) - end - - it 'make the application updating' do - expect(application.cluster).not_to be_nil - service.execute - - expect(application).to be_updating - end - - it 'schedule async installation status check' do - expect(ClusterWaitForAppInstallationWorker).to receive(:perform_in).once - - service.execute - end - end - - context 'when kubernetes cluster communication fails' do - let(:error) { Kubeclient::HttpError.new(500, 'system failure', nil) } - - before do - expect(helm_client).to receive(:update).with(update_command).and_raise(error) - end - - include_examples 'logs kubernetes errors' do - let(:error_name) { 'Kubeclient::HttpError' } - let(:error_message) { 'system failure' } - let(:error_code) { 500 } - end - - it 'make the application errored' do - service.execute - - expect(application).to be_update_errored - expect(application.status_reason).to eq(_('Kubernetes error: %{error_code}') % { error_code: 500 }) - end - end - - context 'a non kubernetes error happens' do - let(:application) { create(:clusters_applications_knative, :scheduled) } - let(:error) { StandardError.new('something bad happened') } - - include_examples 'logs kubernetes errors' do - let(:error_name) { 'StandardError' } - let(:error_message) { 'something bad happened' } - let(:error_code) { nil } - end - - before do - expect(helm_client).to receive(:update).with(update_command).and_raise(error) - end - - it 'make the application errored' do - service.execute - - expect(application).to be_update_errored - expect(application.status_reason).to eq(_('Failed to update.')) - end - end - end -end diff --git a/spec/services/clusters/applications/update_service_spec.rb b/spec/services/clusters/applications/update_service_spec.rb deleted file mode 100644 index 4c05a12a4a1..00000000000 --- a/spec/services/clusters/applications/update_service_spec.rb +++ /dev/null @@ -1,91 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -RSpec.describe Clusters::Applications::UpdateService do - include TestRequestHelpers - - let(:cluster) { create(:cluster, :project, :provided_by_gcp) } - let(:user) { create(:user) } - let(:params) { { application: 'knative', hostname: 'update.example.com', pages_domain_id: domain.id } } - let(:service) { described_class.new(cluster, user, params) } - let(:domain) { create(:pages_domain, :instance_serverless) } - - subject { service.execute(test_request) } - - describe '#execute' do - before do - allow(ClusterPatchAppWorker).to receive(:perform_async) - end - - context 'application is not installed' do - it 'raises Clusters::Applications::BaseService::InvalidApplicationError' do - expect(ClusterPatchAppWorker).not_to receive(:perform_async) - - expect { subject } - .to raise_exception { Clusters::Applications::BaseService::InvalidApplicationError } - .and not_change { Clusters::Applications::Knative.count } - .and not_change { Clusters::Applications::Knative.with_status(:scheduled).count } - end - end - - context 'application is installed' do - context 'application is schedulable' do - let!(:application) do - create(:clusters_applications_knative, status: 3, cluster: cluster) - end - - it 'updates the application data' do - expect do - subject - end.to change { application.reload.hostname }.to(params[:hostname]) - end - - it 'makes application scheduled!' do - subject - - expect(application.reload).to be_scheduled - end - - it 'schedules ClusterPatchAppWorker' do - expect(ClusterPatchAppWorker).to receive(:perform_async) - - subject - end - - context 'knative application' do - let(:associate_domain_service) { double('AssociateDomainService') } - - it 'executes AssociateDomainService' do - expect(Serverless::AssociateDomainService).to receive(:new) do |knative, args| - expect(knative.id).to eq(application.id) - expect(args[:pages_domain_id]).to eq(params[:pages_domain_id]) - expect(args[:creator]).to eq(user) - - associate_domain_service - end - - expect(associate_domain_service).to receive(:execute) - - subject - end - end - end - - context 'application is not schedulable' do - let!(:application) do - create(:clusters_applications_knative, status: 4, cluster: cluster) - end - - it 'raises StateMachines::InvalidTransition' do - expect(ClusterPatchAppWorker).not_to receive(:perform_async) - - expect { subject } - .to raise_exception { StateMachines::InvalidTransition } - .and not_change { application.reload.hostname } - .and not_change { Clusters::Applications::Knative.with_status(:scheduled).count } - end - end - end - end -end diff --git a/spec/support/shared_contexts/navbar_structure_context.rb b/spec/support/shared_contexts/navbar_structure_context.rb index 138150a371a..af35a5ff068 100644 --- a/spec/support/shared_contexts/navbar_structure_context.rb +++ b/spec/support/shared_contexts/navbar_structure_context.rb @@ -67,8 +67,8 @@ RSpec.shared_context 'project navbar structure' do { nav_item: _('Deployments'), nav_sub_items: [ - _('Feature Flags'), _('Environments'), + _('Feature Flags'), _('Releases') ] }, diff --git a/spec/support/shared_examples/models/integrations/has_web_hook_shared_examples.rb b/spec/support/shared_examples/models/integrations/has_web_hook_shared_examples.rb index 31ec25249d7..a764d47d7c0 100644 --- a/spec/support/shared_examples/models/integrations/has_web_hook_shared_examples.rb +++ b/spec/support/shared_examples/models/integrations/has_web_hook_shared_examples.rb @@ -38,7 +38,7 @@ RSpec.shared_examples Integrations::HasWebHook do end describe '#url_variables' do - it 'returns a string' do + it 'returns a hash' do expect(integration.url_variables).to be_a(Hash) end end diff --git a/yarn.lock b/yarn.lock index 2f7bc645901..41e06df5fab 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1113,10 +1113,10 @@ resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-3.5.0.tgz#226240b7aa93db986f4c6f7738ca2a1846b5234d" integrity sha512-/djPsJzUY7i/FaydRVt3ZyXiFf5HGNo1rg2mfLn1EpXvT4zc2ag5ECwnYcPb97KgqFCJX6Tk+Ndu8Wh3GoOW1g== -"@gitlab/ui@49.2.0": - version "49.2.0" - resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-49.2.0.tgz#45eedbe943bccbb6d986d66bf7c6294c82e89366" - integrity sha512-S7jfYtmh2Z36bum48aqb+NFLl/WAqow5gOXfWjdl1lGXjpKZ27neJPTWfpYi2PRyhmPs8ptVg7zKaxXJMZ7cgA== +"@gitlab/ui@49.2.1": + version "49.2.1" + resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-49.2.1.tgz#362dda68799d6ecfd32c8e0a4eb1409f20ddec4d" + integrity sha512-dutmZTGQDDn7nPzGFtI6YEnqF7yhnD6tY6ymGQ1U0bkdDcjR8GOMvDn3Gc09505go6ESt0A4dXwleboDgoFP0w== dependencies: "@popperjs/core" "^2.11.2" bootstrap-vue "2.20.1" |