diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-29 15:11:22 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-09-29 15:11:22 +0300 |
commit | 5f8d4d631d241c993c2cac54db3494f474b21dc1 (patch) | |
tree | ad82966503efbc7d8dc37601ff498af85d76b495 | |
parent | c724e639a91a4d112b7f0a05b3c6a0ffa6baa7a4 (diff) |
Add latest changes from gitlab-org/gitlab@master
113 files changed, 2062 insertions, 316 deletions
diff --git a/.gitlab/ci/rules.gitlab-ci.yml b/.gitlab/ci/rules.gitlab-ci.yml index 41c8525c0a4..62fb7c75cca 100644 --- a/.gitlab/ci/rules.gitlab-ci.yml +++ b/.gitlab/ci/rules.gitlab-ci.yml @@ -653,7 +653,6 @@ .rails:rules:decomposed-databases: rules: - <<: *if-merge-request-run-decomposed - allow_failure: true .rails:rules:ee-and-foss-migration: rules: diff --git a/app/assets/javascripts/behaviors/preview_markdown.js b/app/assets/javascripts/behaviors/preview_markdown.js index a1911585f80..a548b283142 100644 --- a/app/assets/javascripts/behaviors/preview_markdown.js +++ b/app/assets/javascripts/behaviors/preview_markdown.js @@ -81,7 +81,7 @@ MarkdownPreview.prototype.fetchMarkdownPreview = function (text, url, success) { }) .catch(() => createFlash({ - message: __('An error occurred while fetching markdown preview'), + message: __('An error occurred while fetching Markdown preview'), }), ); }; diff --git a/app/assets/javascripts/cycle_analytics/index.js b/app/assets/javascripts/cycle_analytics/index.js index 620da0104e0..34ef03409b8 100644 --- a/app/assets/javascripts/cycle_analytics/index.js +++ b/app/assets/javascripts/cycle_analytics/index.js @@ -45,6 +45,7 @@ export default () => { new Vue({ el, name: 'CycleAnalytics', + apolloProvider: {}, store, render: (createElement) => createElement(CycleAnalytics, { diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue index 051ab710e5f..7acb5549273 100644 --- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue +++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue @@ -25,7 +25,7 @@ export default { lazy: true, }, translations: { - cronPlaceholder: __('* * * * *'), + cronPlaceholder: '* * * * *', cronSyntaxInstructions: __( 'Define a custom deploy freeze pattern with %{cronSyntaxStart}cron syntax%{cronSyntaxEnd}', ), diff --git a/app/assets/javascripts/notifications/constants.js b/app/assets/javascripts/notifications/constants.js index 4f875977d78..f5891c9acb5 100644 --- a/app/assets/javascripts/notifications/constants.js +++ b/app/assets/javascripts/notifications/constants.js @@ -31,7 +31,7 @@ export const i18n = { title: __('Custom notification events'), bodyTitle: __('Notification events'), bodyMessage: __( - 'Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notificationLinkStart} notification emails%{notificationLinkEnd}.', + 'Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notificationLinkStart}notification emails%{notificationLinkEnd}.', ), }, eventNames: { diff --git a/app/assets/javascripts/packages_and_registries/settings/project/constants.js b/app/assets/javascripts/packages_and_registries/settings/project/constants.js index 165c4aae3cb..4d477fbd05d 100644 --- a/app/assets/javascripts/packages_and_registries/settings/project/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/project/constants.js @@ -73,6 +73,7 @@ export const OLDER_THAN_OPTIONS = [ { key: 'SEVEN_DAYS', variable: 7, default: false }, { key: 'FOURTEEN_DAYS', variable: 14, default: false }, { key: 'THIRTY_DAYS', variable: 30, default: false }, + { key: 'SIXTY_DAYS', variable: 60, default: false }, { key: 'NINETY_DAYS', variable: 90, default: true }, ]; diff --git a/app/assets/javascripts/pages/profiles/index.js b/app/assets/javascripts/pages/profiles/index.js index 80bc32dd43f..6afb3636998 100644 --- a/app/assets/javascripts/pages/profiles/index.js +++ b/app/assets/javascripts/pages/profiles/index.js @@ -2,6 +2,7 @@ import $ from 'jquery'; import '~/profile/gl_crop'; import Profile from '~/profile/profile'; import initSearchSettings from '~/search_settings'; +import initPasswordPrompt from './password_prompt'; // eslint-disable-next-line func-names $(document).on('input.ssh_key', '#key_key', function () { @@ -19,3 +20,4 @@ $(document).on('input.ssh_key', '#key_key', function () { new Profile(); // eslint-disable-line no-new initSearchSettings(); +initPasswordPrompt(); diff --git a/app/assets/javascripts/pages/profiles/password_prompt/constants.js b/app/assets/javascripts/pages/profiles/password_prompt/constants.js new file mode 100644 index 00000000000..99b8442c928 --- /dev/null +++ b/app/assets/javascripts/pages/profiles/password_prompt/constants.js @@ -0,0 +1,9 @@ +import { __, s__ } from '~/locale'; + +export const I18N_PASSWORD_PROMPT_TITLE = s__('PasswordPrompt|Confirm password to continue'); +export const I18N_PASSWORD_PROMPT_FORM_LABEL = s__( + 'PasswordPrompt|Please enter your password to confirm', +); +export const I18N_PASSWORD_PROMPT_ERROR_MESSAGE = s__('PasswordPrompt|Password is required'); +export const I18N_PASSWORD_PROMPT_CONFIRM_BUTTON = s__('PasswordPrompt|Confirm password'); +export const I18N_PASSWORD_PROMPT_CANCEL_BUTTON = __('Cancel'); diff --git a/app/assets/javascripts/pages/profiles/password_prompt/index.js b/app/assets/javascripts/pages/profiles/password_prompt/index.js new file mode 100644 index 00000000000..20645112893 --- /dev/null +++ b/app/assets/javascripts/pages/profiles/password_prompt/index.js @@ -0,0 +1,58 @@ +import Vue from 'vue'; +import Translate from '~/vue_shared/translate'; +import PasswordPromptModal from './password_prompt_modal.vue'; + +Vue.use(Translate); + +const emailFieldSelector = '#user_email'; +const editFormSelector = '.js-password-prompt-form'; +const passwordPromptFieldSelector = '.js-password-prompt-field'; +const passwordPromptBtnSelector = '.js-password-prompt-btn'; + +const passwordPromptModalId = 'password-prompt-modal'; + +const getEmailValue = () => document.querySelector(emailFieldSelector).value.trim(); +const passwordPromptButton = document.querySelector(passwordPromptBtnSelector); +const field = document.querySelector(passwordPromptFieldSelector); +const form = document.querySelector(editFormSelector); + +const handleConfirmPassword = (pw) => { + // update the validation_password field + field.value = pw; + // submit the form + form.submit(); +}; + +export default () => { + const passwordPromptModalEl = document.getElementById(passwordPromptModalId); + + if (passwordPromptModalEl && field) { + return new Vue({ + el: passwordPromptModalEl, + data() { + return { + initialEmail: '', + }; + }, + mounted() { + this.initialEmail = getEmailValue(); + passwordPromptButton.addEventListener('click', this.handleSettingsUpdate); + }, + methods: { + handleSettingsUpdate(ev) { + const email = getEmailValue(); + if (email !== this.initialEmail) { + ev.preventDefault(); + this.$root.$emit('bv::show::modal', passwordPromptModalId, passwordPromptBtnSelector); + } + }, + }, + render(createElement) { + return createElement(PasswordPromptModal, { + props: { handleConfirmPassword }, + }); + }, + }); + } + return null; +}; diff --git a/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue b/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue new file mode 100644 index 00000000000..44728ea9cdf --- /dev/null +++ b/app/assets/javascripts/pages/profiles/password_prompt/password_prompt_modal.vue @@ -0,0 +1,82 @@ +<script> +import { GlModal, GlForm, GlFormGroup, GlFormInput } from '@gitlab/ui'; +import { + I18N_PASSWORD_PROMPT_TITLE, + I18N_PASSWORD_PROMPT_FORM_LABEL, + I18N_PASSWORD_PROMPT_ERROR_MESSAGE, + I18N_PASSWORD_PROMPT_CANCEL_BUTTON, + I18N_PASSWORD_PROMPT_CONFIRM_BUTTON, +} from './constants'; + +export default { + components: { + GlModal, + GlForm, + GlFormGroup, + GlFormInput, + }, + props: { + handleConfirmPassword: { + type: Function, + required: true, + }, + }, + data() { + return { + passwordCheck: '', + }; + }, + computed: { + isValid() { + return Boolean(this.passwordCheck.length); + }, + primaryProps() { + return { + text: I18N_PASSWORD_PROMPT_CONFIRM_BUTTON, + attributes: [{ variant: 'danger' }, { category: 'primary' }, { disabled: !this.isValid }], + }; + }, + }, + methods: { + onConfirmPassword() { + this.handleConfirmPassword(this.passwordCheck); + }, + }, + cancelProps: { + text: I18N_PASSWORD_PROMPT_CANCEL_BUTTON, + }, + i18n: { + title: I18N_PASSWORD_PROMPT_TITLE, + formLabel: I18N_PASSWORD_PROMPT_FORM_LABEL, + errorMessage: I18N_PASSWORD_PROMPT_ERROR_MESSAGE, + }, +}; +</script> + +<template> + <gl-modal + data-testid="password-prompt-modal" + modal-id="password-prompt-modal" + :title="$options.i18n.title" + :action-primary="primaryProps" + :action-cancel="$options.cancelProps" + @primary="onConfirmPassword" + > + <gl-form @submit.prevent="onConfirmPassword"> + <gl-form-group + :label="$options.i18n.formLabel" + label-for="password-prompt-confirmation" + :invalid-feedback="$options.i18n.errorMessage" + :state="isValid" + > + <gl-form-input + id="password-prompt-confirmation" + v-model="passwordCheck" + name="password-confirmation" + type="password" + data-testid="password-prompt-field" + /> + </gl-form-group> + </gl-form> + </gl-modal> +</template> diff --git a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js index 02baa76f627..d8f15cfde91 100644 --- a/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js +++ b/app/assets/javascripts/pipelines/components/pipelines_list/tokens/constants.js @@ -2,51 +2,51 @@ import { s__ } from '~/locale'; export const PIPELINE_SOURCES = [ { - text: s__('Pipeline|Source|Push'), + text: s__('PipelineSource|Push'), value: 'push', }, { - text: s__('Pipeline|Source|Web'), + text: s__('PipelineSource|Web'), value: 'web', }, { - text: s__('Pipeline|Source|Trigger'), + text: s__('PipelineSource|Trigger'), value: 'trigger', }, { - text: s__('Pipeline|Source|Schedule'), + text: s__('PipelineSource|Schedule'), value: 'schedule', }, { - text: s__('Pipeline|Source|API'), + text: s__('PipelineSource|API'), value: 'api', }, { - text: s__('Pipeline|Source|External'), + text: s__('PipelineSource|External'), value: 'external', }, { - text: s__('Pipeline|Source|Pipeline'), + text: s__('PipelineSource|Pipeline'), value: 'pipeline', }, { - text: s__('Pipeline|Source|Chat'), + text: s__('PipelineSource|Chat'), value: 'chat', }, { - text: s__('Pipeline|Source|Web IDE'), + text: s__('PipelineSource|Web IDE'), value: 'webide', }, { - text: s__('Pipeline|Source|Merge Request'), + text: s__('PipelineSource|Merge Request'), value: 'merge_request_event', }, { - text: s__('Pipeline|Source|External Pull Request'), + text: s__('PipelineSource|External Pull Request'), value: 'external_pull_request_event', }, { - text: s__('Pipeline|Source|Parent Pipeline'), + text: s__('PipelineSource|Parent Pipeline'), value: 'parent_pipeline', }, ]; 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 c042f0eef5f..7d23c7033f3 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_block.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_block.vue @@ -123,7 +123,7 @@ export default { </script> <template> - <div id="related-issues" class="related-issues-block"> + <div id="related-issues" class="related-issues-block gl-mt-5"> <div class="card card-slim gl-overflow-hidden"> <div :class="{ 'panel-empty-heading border-bottom-0': !hasBody }" diff --git a/app/assets/javascripts/related_issues/components/related_issues_list.vue b/app/assets/javascripts/related_issues/components/related_issues_list.vue index 8f486fb1b07..a21e294a34a 100644 --- a/app/assets/javascripts/related_issues/components/related_issues_list.vue +++ b/app/assets/javascripts/related_issues/components/related_issues_list.vue @@ -97,11 +97,7 @@ export default { class="related-issues-token-body bordered-box bg-white" :class="{ 'sortable-container': canReorder }" > - <div - v-if="isFetching" - class="related-issues-loading-icon" - data-qa-selector="related_issues_loading_placeholder" - > + <div v-if="isFetching" class="gl-mb-2" data-qa-selector="related_issues_loading_placeholder"> <gl-loading-icon ref="loadingIcon" size="sm" diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue index db2197ec65e..4564a48fa2d 100644 --- a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue +++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.vue @@ -1,30 +1,31 @@ <script> -import { sprintf, s__ } from '~/locale'; +import { GlSprintf } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +const timeSpent = s__('TimeTracking|%{spentStart}Spent: %{spentEnd}'); export default { name: 'TimeTrackingSpentOnlyPane', + timeSpent, + components: { + GlSprintf, + }, props: { timeSpentHumanReadable: { type: String, required: true, }, }, - computed: { - timeSpent() { - return sprintf( - s__('TimeTracking|%{startTag}Spent: %{endTag}%{timeSpentHumanReadable}'), - { - startTag: '<span class="gl-font-weight-bold">', - endTag: '</span>', - timeSpentHumanReadable: this.timeSpentHumanReadable, - }, - false, - ); - }, - }, }; </script> <template> - <div data-testid="spentOnlyPane" v-html="timeSpent /* eslint-disable-line vue/no-v-html */"></div> + <div data-testid="spentOnlyPane"> + <gl-sprintf :message="$options.timeSpent"> + <template #spent="{ content }"> + <span class="gl-font-weight-bold">{{ content }}</span + >{{ timeSpentHumanReadable }} + </template> + </gl-sprintf> + </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index ba97bb767e2..08f4837a5cf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -1,7 +1,8 @@ <script> import { GlButton, GlLoadingIcon, GlIcon, GlLink, GlBadge, GlSafeHtmlDirective } from '@gitlab/ui'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; -import StatusIcon from '../mr_widget_status_icon.vue'; +import { EXTENSION_ICON_CLASS } from '../../constants'; +import StatusIcon from './status_icon.vue'; export const LOADING_STATES = { collapsedLoading: 'collapsedLoading', @@ -45,14 +46,6 @@ export default { return true; }, statusIconName() { - if (this.isLoadingSummary) { - return 'loading'; - } - - if (this.loadingState === LOADING_STATES.collapsedError) { - return 'warning'; - } - return this.statusIcon(this.collapsedData); }, }, @@ -96,13 +89,18 @@ export default { }); }, }, + EXTENSION_ICON_CLASS, }; </script> <template> - <section class="media-section mr-widget-border-top"> + <section class="media-section mr-widget-border-top" data-testid="widget-extension"> <div class="media gl-p-5"> - <status-icon :status="statusIconName" class="align-self-center" /> + <status-icon + :name="$options.name" + :is-loading="isLoadingSummary" + :icon-name="statusIconName" + /> <div class="media-body d-flex flex-align-self-center align-items-center"> <div class="code-text"> <template v-if="isLoadingSummary"> @@ -114,13 +112,18 @@ export default { v-if="isCollapsible" size="small" class="float-right align-self-center" + data-testid="toggle-button" @click="toggleCollapsed" > {{ isCollapsed ? __('Expand') : __('Collapse') }} </gl-button> </div> </div> - <div v-if="!isCollapsed" class="mr-widget-grouped-section"> + <div + v-if="!isCollapsed" + class="mr-widget-grouped-section" + data-testid="widget-extension-collapsed-section" + > <div v-if="isLoadingExpanded" class="report-block-container"> <gl-loading-icon size="sm" inline /> {{ __('Loading...') }} </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js index 2fd1e17aaa6..46046d16fcf 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/container.js @@ -1,4 +1,4 @@ -import { extensions } from './index'; +import { registeredExtensions } from './index'; export default { props: { @@ -8,6 +8,8 @@ export default { }, }, render(h) { + const { extensions } = registeredExtensions; + if (extensions.length === 0) return null; return h('div', {}, [ diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js index 9796bb44939..bb40e22fe3f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/index.js @@ -1,12 +1,13 @@ +import Vue from 'vue'; import ExtensionBase from './base.vue'; // Holds all the currently registered extensions -export const extensions = []; +export const registeredExtensions = Vue.observable({ extensions: [] }); export const registerExtension = (extension) => { // Pushes into the extenions array a dynamically created Vue component // that gets exteneded from `base.vue` - extensions.push({ + registeredExtensions.extensions.push({ extends: ExtensionBase, name: extension.name, props: extension.props, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue new file mode 100644 index 00000000000..ed6aa8be1d5 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/status_icon.vue @@ -0,0 +1,52 @@ +<script> +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { EXTENSION_ICON_CLASS, EXTENSION_ICONS } from '../../constants'; + +export default { + components: { + GlLoadingIcon, + GlIcon, + }, + props: { + name: { + type: String, + required: true, + }, + isLoading: { + type: Boolean, + required: true, + }, + iconName: { + type: String, + required: true, + }, + }, + computed: { + iconAriaLabel() { + const statusLabel = Object.keys(EXTENSION_ICONS).find( + (k) => EXTENSION_ICONS[k] === this.iconName, + ); + + return `${capitalizeFirstCharacter(statusLabel)} ${this.name}`; + }, + }, + EXTENSION_ICON_CLASS, +}; +</script> + +<template> + <div + :class="[$options.EXTENSION_ICON_CLASS[iconName], { 'mr-widget-extension-icon': !isLoading }]" + class="align-self-center gl-rounded-full gl-mr-3 gl-relative gl-p-2" + > + <gl-loading-icon v-if="isLoading" size="md" inline class="gl-display-block" /> + <gl-icon + v-else + :name="iconName" + :size="16" + :aria-label="iconAriaLabel" + class="gl-display-block" + /> + </div> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index 5edd4684529..c02783be385 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -91,4 +91,19 @@ export const stateToTransitionMap = { export const stateToComponentMap = { [states.MERGING]: classStateMap[stateKey.merging], }; + +export const EXTENSION_ICONS = { + failed: 'status-failed', + warning: 'status-alert', + success: 'status-success', + neutral: 'status-neutral', +}; + +export const EXTENSION_ICON_CLASS = { + [EXTENSION_ICONS.failed]: 'gl-text-red-500', + [EXTENSION_ICONS.warning]: 'gl-text-orange-500', + [EXTENSION_ICONS.success]: 'gl-text-green-500', + [EXTENSION_ICONS.neutral]: 'gl-text-gray-400', +}; + export { STATE_MACHINE }; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js index 6c6f5e7fc73..5eda5c0778f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/issues.js @@ -1,11 +1,12 @@ /* eslint-disable */ +import { EXTENSION_ICONS } from '../constants'; import issuesCollapsedQuery from './issues_collapsed.query.graphql'; import issuesQuery from './issues.query.graphql'; export default { // Give the extension a name // Make it easier to track in Vue dev tools - name: 'WidgetIssues', + name: 'Issues', // Add an array of props // These then get mapped to values stored in the MR Widget store props: ['targetProjectFullPath'], @@ -14,12 +15,12 @@ export default { // Small summary text to be displayed in the collapsed state // Receives the collapsed data as an argument summary(count) { - return `<strong>${count}</strong> open issue`; + return 'Summary text'; }, // Status icon to be used next to the summary text // Receives the collapsed data as an argument statusIcon(count) { - return count > 0 ? 'warning' : 'success'; + return EXTENSION_ICONS.warning; }, }, methods: { diff --git a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue index 7b88b36aa0f..ea507017caa 100644 --- a/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue +++ b/app/assets/javascripts/vue_shared/components/content_viewer/viewers/markdown_viewer.vue @@ -97,7 +97,7 @@ export default { }); }) .catch(() => { - this.previewContent = __('An error occurred while fetching markdown preview'); + this.previewContent = __('An error occurred while fetching Markdown preview'); this.isLoading = false; }); } diff --git a/app/assets/javascripts/vue_shared/components/markdown/field.vue b/app/assets/javascripts/vue_shared/components/markdown/field.vue index 77730ada9bb..86f04c78ebe 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/field.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/field.vue @@ -254,7 +254,7 @@ export default { .then(() => $(this.$refs['markdown-preview']).renderGFM()) .catch(() => createFlash({ - message: __('Error rendering markdown preview'), + message: __('Error rendering Markdown preview'), }), ); }, diff --git a/app/assets/stylesheets/pages/merge_requests.scss b/app/assets/stylesheets/pages/merge_requests.scss index 071a5be073f..59520e629ab 100644 --- a/app/assets/stylesheets/pages/merge_requests.scss +++ b/app/assets/stylesheets/pages/merge_requests.scss @@ -1040,3 +1040,17 @@ $tabs-holder-z-index: 250; margin-bottom: 1px; } } + +.mr-widget-extension-icon::before { + @include gl-content-empty; + @include gl-absolute; + @include gl-left-0; + @include gl-top-0; + @include gl-opacity-3; + @include gl-border-solid; + @include gl-border-4; + @include gl-rounded-full; + + width: 24px; + height: 24px; +} diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index 29ae268ef67..69257081cc9 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -18,7 +18,7 @@ class ProfilesController < Profiles::ApplicationController def update respond_to do |format| - result = Users::UpdateService.new(current_user, user_params.merge(user: @user)).execute + result = Users::UpdateService.new(current_user, user_params.merge(user: @user)).execute(check_password: true) if result[:status] == :success message = s_("Profiles|Profile was successfully updated") @@ -129,6 +129,7 @@ class ProfilesController < Profiles::ApplicationController :job_title, :pronouns, :pronunciation, + :validation_password, status: [:emoji, :message, :availability] ] end diff --git a/app/finders/members_finder.rb b/app/finders/members_finder.rb index ea101cf1dcd..0faafa6df9c 100644 --- a/app/finders/members_finder.rb +++ b/app/finders/members_finder.rb @@ -70,11 +70,16 @@ class MembersFinder end def project_invited_groups - invited_groups_ids_including_ancestors = Gitlab::ObjectHierarchy - .new(project.invited_groups) - .base_and_ancestors - .public_or_visible_to_user(current_user) - .select(:id) + invited_groups_and_ancestors = if ::Feature.enabled?(:linear_members_finder_ancestor_scopes, current_user, default_enabled: :yaml) + project.invited_groups + .self_and_ancestors + else + Gitlab::ObjectHierarchy + .new(project.invited_groups) + .base_and_ancestors + end + + invited_groups_ids_including_ancestors = invited_groups_and_ancestors.public_or_visible_to_user(current_user).select(:id) GroupMember.with_source_id(invited_groups_ids_including_ancestors).non_minimal_access end diff --git a/app/graphql/mutations/clusters/agent_tokens/create.rb b/app/graphql/mutations/clusters/agent_tokens/create.rb new file mode 100644 index 00000000000..07bf2536065 --- /dev/null +++ b/app/graphql/mutations/clusters/agent_tokens/create.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module Mutations + module Clusters + module AgentTokens + class Create < BaseMutation + graphql_name 'ClusterAgentTokenCreate' + + authorize :create_cluster + + ClusterAgentID = ::Types::GlobalIDType[::Clusters::Agent] + + argument :cluster_agent_id, + ClusterAgentID, + required: true, + description: 'Global ID of the cluster agent that will be associated with the new token.' + + argument :description, + GraphQL::Types::String, + required: false, + description: 'Description of the token.' + + argument :name, + GraphQL::Types::String, + required: true, + description: 'Name of the token.' + + field :secret, + GraphQL::Types::String, + null: true, + description: "Token secret value. Make sure you save it - you won't be able to access it again." + + field :token, + Types::Clusters::AgentTokenType, + null: true, + description: 'Token created after mutation.' + + def resolve(args) + cluster_agent = authorized_find!(id: args[:cluster_agent_id]) + + result = ::Clusters::AgentTokens::CreateService + .new( + container: cluster_agent.project, + current_user: current_user, + params: args.merge(agent_id: cluster_agent.id) + ) + .execute + + payload = result.payload + + { + secret: payload[:secret], + token: payload[:token], + errors: Array.wrap(result.message) + } + end + + private + + def find_object(id:) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = ClusterAgentID.coerce_isolated_input(id) + GitlabSchema.find_by_gid(id) + end + end + end + end +end diff --git a/app/graphql/mutations/clusters/agent_tokens/delete.rb b/app/graphql/mutations/clusters/agent_tokens/delete.rb new file mode 100644 index 00000000000..603b6b30910 --- /dev/null +++ b/app/graphql/mutations/clusters/agent_tokens/delete.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Mutations + module Clusters + module AgentTokens + class Delete < BaseMutation + graphql_name 'ClusterAgentTokenDelete' + + authorize :admin_cluster + + TokenID = ::Types::GlobalIDType[::Clusters::AgentToken] + + argument :id, TokenID, + required: true, + description: 'Global ID of the cluster agent token that will be deleted.' + + def resolve(id:) + token = authorized_find!(id: id) + token.destroy + + { errors: errors_on_object(token) } + end + + private + + def find_object(id:) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = TokenID.coerce_isolated_input(id) + GitlabSchema.find_by_gid(id) + end + end + end + end +end diff --git a/app/graphql/mutations/clusters/agents/create.rb b/app/graphql/mutations/clusters/agents/create.rb new file mode 100644 index 00000000000..0896cc7b203 --- /dev/null +++ b/app/graphql/mutations/clusters/agents/create.rb @@ -0,0 +1,38 @@ +# frozen_string_literal: true + +module Mutations + module Clusters + module Agents + class Create < BaseMutation + include FindsProject + + authorize :create_cluster + + graphql_name 'CreateClusterAgent' + + argument :project_path, GraphQL::Types::ID, + required: true, + description: 'Full path of the associated project for this cluster agent.' + + argument :name, GraphQL::Types::String, + required: true, + description: 'Name of the cluster agent.' + + field :cluster_agent, + Types::Clusters::AgentType, + null: true, + description: 'Cluster agent created after mutation.' + + def resolve(project_path:, name:) + project = authorized_find!(project_path) + result = ::Clusters::Agents::CreateService.new(project, current_user).execute(name: name) + + { + cluster_agent: result[:cluster_agent], + errors: Array.wrap(result[:message]) + } + end + end + end + end +end diff --git a/app/graphql/mutations/clusters/agents/delete.rb b/app/graphql/mutations/clusters/agents/delete.rb new file mode 100644 index 00000000000..9ada1f31f60 --- /dev/null +++ b/app/graphql/mutations/clusters/agents/delete.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Mutations + module Clusters + module Agents + class Delete < BaseMutation + graphql_name 'ClusterAgentDelete' + + authorize :admin_cluster + + AgentID = ::Types::GlobalIDType[::Clusters::Agent] + + argument :id, AgentID, + required: true, + description: 'Global ID of the cluster agent that will be deleted.' + + def resolve(id:) + cluster_agent = authorized_find!(id: id) + result = ::Clusters::Agents::DeleteService + .new(container: cluster_agent.project, current_user: current_user) + .execute(cluster_agent) + + { + errors: Array.wrap(result.message) + } + end + + private + + def find_object(id:) + # TODO: remove this line when the compatibility layer is removed + # See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883 + id = AgentID.coerce_isolated_input(id) + GitlabSchema.find_by_gid(id) + end + end + end + end +end diff --git a/app/graphql/types/container_expiration_policy_older_than_enum.rb b/app/graphql/types/container_expiration_policy_older_than_enum.rb index 7364910f8cd..9c32d767caf 100644 --- a/app/graphql/types/container_expiration_policy_older_than_enum.rb +++ b/app/graphql/types/container_expiration_policy_older_than_enum.rb @@ -6,6 +6,7 @@ module Types '7d': 'SEVEN_DAYS', '14d': 'FOURTEEN_DAYS', '30d': 'THIRTY_DAYS', + '60d': 'SIXTY_DAYS', '90d': 'NINETY_DAYS' }.freeze diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index ea50af1c554..b512bfe8dd6 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -31,6 +31,10 @@ module Types mount_mutation Mutations::Boards::Lists::Update mount_mutation Mutations::Boards::Lists::Destroy mount_mutation Mutations::Branches::Create, calls_gitaly: true + mount_mutation Mutations::Clusters::Agents::Create + mount_mutation Mutations::Clusters::Agents::Delete + mount_mutation Mutations::Clusters::AgentTokens::Create + mount_mutation Mutations::Clusters::AgentTokens::Delete mount_mutation Mutations::Commits::Create, calls_gitaly: true mount_mutation Mutations::CustomEmoji::Create, feature_flag: :custom_emoji mount_mutation Mutations::CustomEmoji::Destroy, feature_flag: :custom_emoji diff --git a/app/helpers/search_helper.rb b/app/helpers/search_helper.rb index b8e58e3afb1..cb28025c900 100644 --- a/app/helpers/search_helper.rb +++ b/app/helpers/search_helper.rb @@ -87,9 +87,9 @@ module SearchHelper def search_entries_info_template(collection) if collection.total_pages > 1 - s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for%{term_element}").html_safe + s_("SearchResults|Showing %{from} - %{to} of %{count} %{scope} for %{term_element}").html_safe else - s_("SearchResults|Showing %{count} %{scope} for%{term_element}").html_safe + s_("SearchResults|Showing %{count} %{scope} for %{term_element}").html_safe end end diff --git a/app/models/audit_event.rb b/app/models/audit_event.rb index f17fff742fe..a1c6793607f 100644 --- a/app/models/audit_event.rb +++ b/app/models/audit_event.rb @@ -71,7 +71,7 @@ class AuditEvent < ApplicationRecord end def lazy_author - BatchLoader.for(author_id).batch(replace_methods: false) do |author_ids, loader| + BatchLoader.for(author_id).batch do |author_ids, loader| User.select(:id, :name, :username).where(id: author_ids).find_each do |user| loader.call(user.id, user) end diff --git a/app/models/commit.rb b/app/models/commit.rb index 6c8b4ae1139..553681ee960 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -133,7 +133,7 @@ class Commit end def lazy(container, oid) - BatchLoader.for({ container: container, oid: oid }).batch(replace_methods: false) do |items, loader| + BatchLoader.for({ container: container, oid: oid }).batch do |items, loader| items_by_container = items.group_by { |i| i[:container] } items_by_container.each do |container, commit_ids| diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb index 84a74386ff7..53cc6aabcce 100644 --- a/app/models/concerns/avatarable.rb +++ b/app/models/concerns/avatarable.rb @@ -110,7 +110,7 @@ module Avatarable def retrieve_upload_from_batch(identifier) BatchLoader.for(identifier: identifier, model: self) - .batch(key: self.class, cache: true, replace_methods: false) do |upload_params, loader, args| + .batch(key: self.class) do |upload_params, loader, args| model_class = args[:key] paths = upload_params.flat_map do |params| params[:model].upload_paths(params[:identifier]) diff --git a/app/models/container_expiration_policy.rb b/app/models/container_expiration_policy.rb index 9bacd9a0edf..aecb47f7a03 100644 --- a/app/models/container_expiration_policy.rb +++ b/app/models/container_expiration_policy.rb @@ -74,6 +74,7 @@ class ContainerExpirationPolicy < ApplicationRecord '7d': _('%{days} days until tags are automatically removed') % { days: 7 }, '14d': _('%{days} days until tags are automatically removed') % { days: 14 }, '30d': _('%{days} days until tags are automatically removed') % { days: 30 }, + '60d': _('%{days} days until tags are automatically removed') % { days: 60 }, '90d': _('%{days} days until tags are automatically removed') % { days: 90 } } end diff --git a/app/models/project.rb b/app/models/project.rb index a05a21b6807..126fcc8d7e6 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -1791,7 +1791,7 @@ class Project < ApplicationRecord def open_issues_count(current_user = nil) return Projects::OpenIssuesCountService.new(self, current_user).count unless current_user.nil? - BatchLoader.for(self).batch(replace_methods: false) do |projects, loader| + BatchLoader.for(self).batch do |projects, loader| issues_count_per_project = ::Projects::BatchOpenIssuesCountService.new(projects).refresh_cache_and_retrieve_data issues_count_per_project.each do |project, count| @@ -2256,7 +2256,7 @@ class Project < ApplicationRecord # rubocop: disable CodeReuse/ServiceClass def forks_count - BatchLoader.for(self).batch(replace_methods: false) do |projects, loader| + BatchLoader.for(self).batch do |projects, loader| fork_count_per_project = ::Projects::BatchForksCountService.new(projects).refresh_cache_and_retrieve_data fork_count_per_project.each do |project, count| diff --git a/app/services/clusters/agent_tokens/create_service.rb b/app/services/clusters/agent_tokens/create_service.rb new file mode 100644 index 00000000000..ae2617f510b --- /dev/null +++ b/app/services/clusters/agent_tokens/create_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Clusters + module AgentTokens + class CreateService < ::BaseContainerService + ALLOWED_PARAMS = %i[agent_id description name].freeze + + def execute + return error_no_permissions unless current_user.can?(:create_cluster, container) + + token = ::Clusters::AgentToken.new(filtered_params.merge(created_by_user: current_user)) + + if token.save + ServiceResponse.success(payload: { secret: token.token, token: token }) + else + ServiceResponse.error(message: token.errors.full_messages) + end + end + + private + + def error_no_permissions + ServiceResponse.error(message: s_('ClusterAgent|User has insufficient permissions to create a token for this project')) + end + + def filtered_params + params.slice(*ALLOWED_PARAMS) + end + end + end +end diff --git a/app/services/clusters/agents/create_service.rb b/app/services/clusters/agents/create_service.rb new file mode 100644 index 00000000000..568f168d63b --- /dev/null +++ b/app/services/clusters/agents/create_service.rb @@ -0,0 +1,29 @@ +# frozen_string_literal: true + +module Clusters + module Agents + class CreateService < BaseService + def execute(name:) + return error_no_permissions unless cluster_agent_permissions? + + agent = ::Clusters::Agent.new(name: name, project: project, created_by_user: current_user) + + if agent.save + success.merge(cluster_agent: agent) + else + error(agent.errors.full_messages) + end + end + + private + + def cluster_agent_permissions? + current_user.can?(:admin_pipeline, project) && current_user.can?(:create_cluster, project) + end + + def error_no_permissions + error(s_('ClusterAgent|You have insufficient permissions to create a cluster agent for this project')) + end + end + end +end diff --git a/app/services/clusters/agents/delete_service.rb b/app/services/clusters/agents/delete_service.rb new file mode 100644 index 00000000000..2132dffa606 --- /dev/null +++ b/app/services/clusters/agents/delete_service.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Clusters + module Agents + class DeleteService < ::BaseContainerService + def execute(cluster_agent) + return error_no_permissions unless current_user.can?(:admin_cluster, cluster_agent) + + if cluster_agent.destroy + ServiceResponse.success + else + ServiceResponse.error(message: cluster_agent.errors.full_messages) + end + end + + private + + def error_no_permissions + ServiceResponse.error(message: s_('ClusterAgent|You have insufficient permissions to delete this cluster agent')) + end + end + end +end diff --git a/app/services/users/update_service.rb b/app/services/users/update_service.rb index 23c67231a29..c3df9b153a1 100644 --- a/app/services/users/update_service.rb +++ b/app/services/users/update_service.rb @@ -5,15 +5,18 @@ module Users include NewUserNotifier attr_reader :user, :identity_params + ATTRS_REQUIRING_PASSWORD_CHECK = %w[email].freeze + def initialize(current_user, params = {}) @current_user = current_user + @validation_password = params.delete(:validation_password) @user = params.delete(:user) @status_params = params.delete(:status) @identity_params = params.slice(*identity_attributes) @params = params.dup end - def execute(validate: true, &block) + def execute(validate: true, check_password: false, &block) yield(@user) if block_given? user_exists = @user.persisted? @@ -21,6 +24,11 @@ module Users discard_read_only_attributes assign_attributes + + if check_password && require_password_check? && !@user.valid_password?(@validation_password) + return error(s_("Profiles|Invalid password")) + end + assign_identity build_canonical_email @@ -32,8 +40,8 @@ module Users end end - def execute!(*args, &block) - result = execute(*args, &block) + def execute!(*args, **kargs, &block) + result = execute(*args, **kargs, &block) raise ActiveRecord::RecordInvalid, @user unless result[:status] == :success @@ -42,6 +50,14 @@ module Users private + def require_password_check? + return false unless @user.persisted? + return false if @user.password_automatically_set? + + changes = @user.changed + ATTRS_REQUIRING_PASSWORD_CHECK.any? { |param| changes.include?(param) } + end + def build_canonical_email return unless @user.email_changed? diff --git a/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb b/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb index ab46aaaca1a..32e88047a9c 100644 --- a/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb +++ b/app/views/devise/mailer/_confirmation_instructions_secondary.text.erb @@ -1,4 +1,4 @@ -<%= _(" %{name}, confirm your email address now! ") % { name: @resource.user.name } %> +<%= _("%{name}, confirm your email address now!") % { name: @resource.user.name } %> <%= _("Use the link below to confirm your email address (%{email})") % { email: @resource.email } %> diff --git a/app/views/profiles/_email_settings.html.haml b/app/views/profiles/_email_settings.html.haml index bc678c2c429..1057e96f442 100644 --- a/app/views/profiles/_email_settings.html.haml +++ b/app/views/profiles/_email_settings.html.haml @@ -3,8 +3,11 @@ - email_change_disabled = local_assigns.fetch(:email_change_disabled, nil) - read_only_help_text = readonly ? s_("Profiles|Your email address was automatically set based on your %{provider_label} account") % { provider_label: attribute_provider_label(:email) } : user_email_help_text(@user) - help_text = email_change_disabled ? s_("Your account uses dedicated credentials for the \"%{group_name}\" group and can only be updated through SSO.") % { group_name: @user.managing_group.name } : read_only_help_text +- password_automatically_set = @user.password_automatically_set? = form.text_field :email, required: true, class: 'input-lg gl-form-input', value: (@user.email unless @user.temp_oauth_email?), help: help_text.html_safe, readonly: readonly || email_change_disabled +- unless password_automatically_set + = hidden_field_tag 'user[validation_password]', :validation_password, class: 'js-password-prompt-field', help: s_("Profiles|Enter your password to confirm the email change") = form.select :public_email, options_for_select(@user.public_verified_emails, selected: @user.public_email), { help: s_("Profiles|This email will be displayed on your public profile"), include_blank: s_("Profiles|Do not show on profile") }, control_class: 'select2 input-lg', disabled: email_change_disabled diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 0bb4859dd1e..ad8cde689f9 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -5,7 +5,7 @@ - availability = availability_values - custom_emoji = show_status_emoji?(@user.status) -= bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user gl-mt-3 js-quick-submit gl-show-field-errors' }, authenticity_token: true do |f| += bootstrap_form_for @user, url: profile_path, method: :put, html: { multipart: true, class: 'edit-user gl-mt-3 js-quick-submit gl-show-field-errors js-password-prompt-form', remote: true }, authenticity_token: true do |f| = form_errors(@user) .row.js-search-settings-section @@ -124,9 +124,11 @@ .help-block = s_("Profiles|Choose to show contributions of private projects on your public profile without any project, repository or organization information") %hr - = f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-confirm gl-mr-3' + = f.submit s_("Profiles|Update profile settings"), class: 'gl-button btn btn-confirm gl-mr-3 js-password-prompt-btn' = link_to _("Cancel"), user_path(current_user), class: 'gl-button btn btn-default btn-cancel' +#password-prompt-modal + .modal.modal-profile-crop{ data: { cropper_css_path: ActionController::Base.helpers.stylesheet_path('lazy_bundles/cropper.css') } } .modal-dialog .modal-content diff --git a/app/views/projects/issues/_related_issues.html.haml b/app/views/projects/issues/_related_issues.html.haml index d131d20f079..bab37609c20 100644 --- a/app/views/projects/issues/_related_issues.html.haml +++ b/app/views/projects/issues/_related_issues.html.haml @@ -3,4 +3,3 @@ can_add_related_issues: "#{can?(current_user, :admin_issue_link, @issue)}", help_path: help_page_path('user/project/issues/related_issues'), show_categorized_issues: "false" } } - - render('projects/issues/related_issues_block') diff --git a/app/views/projects/issues/_related_issues_block.html.haml b/app/views/projects/issues/_related_issues_block.html.haml deleted file mode 100644 index 8d986b64b1d..00000000000 --- a/app/views/projects/issues/_related_issues_block.html.haml +++ /dev/null @@ -1,5 +0,0 @@ -.related-issues-block - .card.card-slim - .card-header.panel-empty-heading.border-bottom-0 - %h3.card-title.mt-0.mb-0.h5 - = _('Linked issues') diff --git a/app/views/search/results/_issuable.html.haml b/app/views/search/results/_issuable.html.haml index 5645fbfb238..41058034d6f 100644 --- a/app/views/search/results/_issuable.html.haml +++ b/app/views/search/results/_issuable.html.haml @@ -13,7 +13,8 @@ = highlight_and_truncate_issuable(issuable, @search_term, @search_highlight) .col-sm-3.gl-mt-3.gl-sm-mt-0.gl-text-right - if issuable.respond_to?(:upvotes_count) && issuable.upvotes_count > 0 - %li.issuable-upvotes.gl-list-style-none.has-tooltip{ title: _('Upvotes') } - = sprite_icon('thumb-up', css_class: "gl-vertical-align-middle") - = issuable.upvotes_count + %li.issuable-upvotes.gl-list-style-none + %span.has-tooltip{ title: _('Upvotes') } + = sprite_icon('thumb-up', css_class: "gl-vertical-align-middle") + = issuable.upvotes_count %span.gl-text-gray-500= sprintf(s_('updated %{time_ago}'), { time_ago: time_ago_with_tooltip(issuable.updated_at, placement: 'bottom') }).html_safe diff --git a/app/workers/concerns/gitlab/github_import/object_importer.rb b/app/workers/concerns/gitlab/github_import/object_importer.rb index a377b7a2000..e1f404b250d 100644 --- a/app/workers/concerns/gitlab/github_import/object_importer.rb +++ b/app/workers/concerns/gitlab/github_import/object_importer.rb @@ -26,8 +26,7 @@ module Gitlab object = representation_class.from_json_hash(hash) # To better express in the logs what object is being imported. - self.github_id = object.attributes.fetch(:github_id) - + self.github_identifiers = object.github_identifiers info(project.id, message: 'starting importer') importer_class.new(object, project, client).execute @@ -35,10 +34,10 @@ module Gitlab Gitlab::GithubImport::ObjectCounter.increment(project, object_type, :imported) info(project.id, message: 'importer finished') - rescue KeyError => e + rescue NoMethodError => e # This exception will be more useful in development when a new # Representation is created but the developer forgot to add a - # `:github_id` field. + # `:github_identifiers` field. Gitlab::Import::ImportFailureService.track( project_id: project.id, error_source: importer_class.name, @@ -72,7 +71,7 @@ module Gitlab private - attr_accessor :github_id + attr_accessor :github_identifiers def info(project_id, extra = {}) Logger.info(log_attributes(project_id, extra)) @@ -82,7 +81,7 @@ module Gitlab extra.merge( project_id: project_id, importer: importer_class.name, - github_id: github_id + github_identifiers: github_identifiers ) end end diff --git a/config/feature_flags/development/linear_members_finder_ancestor_scopes.yml b/config/feature_flags/development/linear_members_finder_ancestor_scopes.yml new file mode 100644 index 00000000000..6bd5e164324 --- /dev/null +++ b/config/feature_flags/development/linear_members_finder_ancestor_scopes.yml @@ -0,0 +1,8 @@ +--- +name: linear_members_finder_ancestor_scopes +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/70583 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/341347 +milestone: '14.4' +type: development +group: group::access +default_enabled: false diff --git a/config/initializers/batch_loader.rb b/config/initializers/batch_loader.rb index d88b43fbcea..e542a26cb81 100644 --- a/config/initializers/batch_loader.rb +++ b/config/initializers/batch_loader.rb @@ -1,3 +1,13 @@ # frozen_string_literal: true Rails.application.config.middleware.use(BatchLoader::Middleware) + +# Disables replace_methods by default. +# See https://github.com/exAspArk/batch-loader#replacing-methods for more information. +module BatchLoaderWithoutMethodReplacementByDefault + def batch(replace_methods: false, **kw_args, &batch_block) + super + end +end + +BatchLoader.prepend(BatchLoaderWithoutMethodReplacementByDefault) diff --git a/config/initializers/gettext_rails_i18n_patch.rb b/config/initializers/gettext_rails_i18n_patch.rb index c23049e93c9..3c994516b2d 100644 --- a/config/initializers/gettext_rails_i18n_patch.rb +++ b/config/initializers/gettext_rails_i18n_patch.rb @@ -1,30 +1,8 @@ # frozen_string_literal: true -require 'gettext_i18n_rails/haml_parser' require 'gettext_i18n_rails_js/parser/javascript' require 'json' -VUE_TRANSLATE_REGEX = /((%[\w.-]+)(?:\s))?{{ (N|n|s)?__\((.*)\) }}/.freeze - -module GettextI18nRails - class HamlParser - singleton_class.send(:alias_method, :old_convert_to_code, :convert_to_code) - - # We need to convert text in Mustache format - # to a format that can be parsed by Gettext scripts. - # If we found a content like "{{ __('Stage') }}" - # in a HAML file we convert it to "= _('Stage')", that way - # it can be processed by the "rake gettext:find" script. - # - # Overwrites: https://github.com/grosser/gettext_i18n_rails/blob/8396387a431e0f8ead72fc1cd425cad2fa4992f2/lib/gettext_i18n_rails/haml_parser.rb#L9 - def self.convert_to_code(text) - text.gsub!(VUE_TRANSLATE_REGEX, "\\2= \\3_(\\4)") - - old_convert_to_code(text) - end - end -end - module GettextI18nRailsJs module Parser module Javascript diff --git a/config/metrics/counts_all/20210915082040_projects_with_expiration_policy_enabled_with_older_than_set_to_60d.yml b/config/metrics/counts_all/20210915082040_projects_with_expiration_policy_enabled_with_older_than_set_to_60d.yml new file mode 100644 index 00000000000..1a9fe349a90 --- /dev/null +++ b/config/metrics/counts_all/20210915082040_projects_with_expiration_policy_enabled_with_older_than_set_to_60d.yml @@ -0,0 +1,22 @@ +--- +data_category: optional +key_path: counts.projects_with_expiration_policy_enabled_with_older_than_set_to_60d +description: A count of projects with the cleanup policy set delete tags older than + 60 days +product_section: ops +product_stage: package +product_group: group::package +product_category: container registry +value_type: number +status: active +time_frame: all +data_source: database +distribution: +- ee +- ce +tier: +- free +- premium +- ultimate +performance_indicator_type: [] +milestone: "14.4" diff --git a/config/routes/project.rb b/config/routes/project.rb index cbd2f5ac839..bfb7802b3de 100644 --- a/config/routes/project.rb +++ b/config/routes/project.rb @@ -298,6 +298,8 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do end end + resources :cluster_agents, only: [:show], param: :name + concerns :clusterable namespace :serverless do diff --git a/doc/administration/environment_variables.md b/doc/administration/environment_variables.md index 057abce0ed5..3af80363916 100644 --- a/doc/administration/environment_variables.md +++ b/doc/administration/environment_variables.md @@ -21,6 +21,7 @@ You can use the following environment variables to override certain values: |--------------------------------------------|---------|---------------------------------------------------------------------------------------------------------| | `DATABASE_URL` | string | The database URL; is of the form: `postgresql://localhost/blog_development`. | | `ENABLE_BOOTSNAP` | string | Enables Bootsnap for speeding up initial Rails boot (`1` to enable). | +| `EXTERNAL_URL` | string | Specify the external URL at the [time of installation](https://docs.gitlab.com/omnibus/settings/configuration.html#specifying-the-external-url-at-the-time-of-installation). | | `EXTERNAL_VALIDATION_SERVICE_TIMEOUT` | integer | Timeout, in seconds, for an [external CI/CD pipeline validation service](external_pipeline_validation.md). Default is `5`. | | `EXTERNAL_VALIDATION_SERVICE_URL` | string | URL to an [external CI/CD pipeline validation service](external_pipeline_validation.md). | | `EXTERNAL_VALIDATION_SERVICE_TOKEN` | string | The `X-Gitlab-Token` for authentication with an [external CI/CD pipeline validation service](external_pipeline_validation.md). | diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 33a1d39a911..3ebaa3f159b 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -15460,6 +15460,7 @@ Conan file types. | <a id="containerexpirationpolicyolderthanenumfourteen_days"></a>`FOURTEEN_DAYS` | 14 days until tags are automatically removed. | | <a id="containerexpirationpolicyolderthanenumninety_days"></a>`NINETY_DAYS` | 90 days until tags are automatically removed. | | <a id="containerexpirationpolicyolderthanenumseven_days"></a>`SEVEN_DAYS` | 7 days until tags are automatically removed. | +| <a id="containerexpirationpolicyolderthanenumsixty_days"></a>`SIXTY_DAYS` | 60 days until tags are automatically removed. | | <a id="containerexpirationpolicyolderthanenumthirty_days"></a>`THIRTY_DAYS` | 30 days until tags are automatically removed. | ### `ContainerRepositoryCleanupStatus` diff --git a/doc/ci/docker/using_kaniko.md b/doc/ci/docker/using_kaniko.md index 6886899a54b..853b1aaca42 100644 --- a/doc/ci/docker/using_kaniko.md +++ b/doc/ci/docker/using_kaniko.md @@ -64,7 +64,7 @@ build: entrypoint: [""] script: - mkdir -p /kaniko/.docker - - echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(echo -n ${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD} | base64)\"}}}" > /kaniko/.docker/config.json + - echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(echo -n ${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD} | base64 | tr -d '\n')\"}}}" > /kaniko/.docker/config.json - /kaniko/executor --context $CI_PROJECT_DIR --dockerfile $CI_PROJECT_DIR/Dockerfile --destination $CI_REGISTRY_IMAGE:$CI_COMMIT_TAG rules: - if: $CI_COMMIT_TAG @@ -91,7 +91,7 @@ build: - mkdir -p /kaniko/.docker - |- KANIKOPROXYBUILDARGS="" - KANIKOCFG="{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(echo -n ${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD} | base64)\"}}}" + KANIKOCFG="{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(echo -n ${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD} | base64 | tr -d '\n')\"}}}" if [ "x${http_proxy}" != "x" -o "x${https_proxy}" != "x" ]; then KANIKOCFG="${KANIKOCFG}, \"proxies\": { \"default\": { \"httpProxy\": \"${http_proxy}\", \"httpsProxy\": \"${https_proxy}\", \"noProxy\": \"${no_proxy}\"}}" KANIKOPROXYBUILDARGS="--build-arg http_proxy=${http_proxy} --build-arg https_proxy=${https_proxy} --build-arg no_proxy=${no_proxy}" @@ -120,7 +120,7 @@ store: ```yaml before_script: - mkdir -p /kaniko/.docker - - echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(echo -n ${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD} | base64)\"}}}" > /kaniko/.docker/config.json + - echo "{\"auths\":{\"$CI_REGISTRY\":{\"auth\":\"$(echo -n ${CI_REGISTRY_USER}:${CI_REGISTRY_PASSWORD} | base64 | tr -d '\n')\"}}}" > /kaniko/.docker/config.json - | echo "-----BEGIN CERTIFICATE----- ... diff --git a/doc/development/code_review.md b/doc/development/code_review.md index 8fa7d1be04c..89516c2168b 100644 --- a/doc/development/code_review.md +++ b/doc/development/code_review.md @@ -106,6 +106,7 @@ with [domain expertise](#domain-experts). 1. If your merge request includes user-facing changes (*3*), it must be **approved by a [Product Designer](https://about.gitlab.com/handbook/engineering/projects/#gitlab_reviewers_UX)**, based on assignments in the appropriate [DevOps stage group](https://about.gitlab.com/handbook/product/categories/#devops-stages). + See the [design and user interface guidelines](contributing/design.md) for details. 1. If your merge request includes adding a new JavaScript library (*1*)... - If the library significantly increases the [bundle size](https://gitlab.com/gitlab-org/frontend/playground/webpack-memory-metrics/-/blob/master/doc/report.md), it must diff --git a/doc/development/contributing/design.md b/doc/development/contributing/design.md index 9e8375fcbdd..6831fb186d4 100644 --- a/doc/development/contributing/design.md +++ b/doc/development/contributing/design.md @@ -5,34 +5,102 @@ group: Development info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments --- -# Implement design & UI elements +# Design and user interface changes -For guidance on UX implementation at GitLab, please refer to our [Design System](https://design.gitlab.com/). +Follow these guidelines when contributing or reviewing design and user interface +(UI) changes. Refer to our [code review guide](../code_review.md) for broader +advice and best practices for code review in general. -The UX team uses labels to manage their workflow. +The basis for most of these guidelines is [Pajamas](https://design.gitlab.com/), +GitLab design system. We encourage you to [contribute to Pajamas](https://design.gitlab.com/get-started/contribute) +with additions and improvements. -The `~UX` label on an issue is a signal to the UX team that it will need UX attention. -To better understand the priority by which UX tackles issues, see the [UX section](https://about.gitlab.com/handbook/engineering/ux/) of the handbook. +## Merge request reviews -Once an issue has been worked on and is ready for development, a UXer removes the `~UX` label and applies the `~"UX ready"` label to that issue. +As a merge request (MR) author, you must include _Before_ and _After_ +screenshots (or videos) of your changes in the description, as explained in our +[MR workflow](merge_request_workflow.md). These screenshots/videos are very helpful +for all reviewers and can speed up the review process, especially if the changes +are small. -There is a special type label called `~"product discovery"` intended for UX (user experience), -PM (product manager), FE (frontend), and BE (backend). It represents a discovery issue to discuss the problem and -potential solutions. The final output for this issue could be a doc of -requirements, a design artifact, or even a prototype. The solution will be -developed in a subsequent milestone. +## Checklist -`~"product discovery"` issues are like any other issue and should contain a milestone label, `~Deliverable` or `~Stretch`, when scheduled in the current milestone. +Check these aspects both when _designing_ and _reviewing_ UI changes. -The initial issue should be about the problem we are solving. If a separate [product discovery issue](https://about.gitlab.com/handbook/engineering/ux/ux-department-workflow/#how-we-use-labels) -is needed for additional research and design work, it will be created by a PM or UX person. -Assign the `~UX`, `~"product discovery"` and `~Deliverable` labels, add a milestone and -use a title that makes it clear that the scheduled issue is product discovery -(for example, `Product discovery for XYZ`). +### Writing -In order to complete a product discovery issue in a release, you must complete the following: +- Follow [Pajamas](https://design.gitlab.com/content/punctuation/) as the primary + guidelines for UI text and [documentation style guide](../documentation/styleguide/index.md) + as the secondary. +- Use clear and consistent [terminology](https://design.gitlab.com/content/terminology). +- Check grammar and spelling. +- Consider help content and follow its [guidelines](https://design.gitlab.com/usability/helping-users). +- Request review from the [appropriate Technical Writer](https://about.gitlab.com/handbook/engineering/ux/technical-writing/#designated-technical-writers), + indicating any specific files or lines they should review, and how to preview + or understand the location/context of the text from the user's perspective. -1. UXer removes the `~UX` label, adds the `~"UX ready"` label. -1. Modify the issue description in the product discovery issue to contain the final design. If it makes sense, the original information indicating the need for the design can be moved to a lower "Original Information" section. -1. Copy the design to the description of the delivery issue for which the product discovery issue was created. Do not simply refer to the product discovery issue as a separate source of truth. -1. In some cases, a product discovery issue also identifies future enhancements that will not go into the issue that originated the product discovery issue. For these items, create new issues containing the designs to ensure they are not lost. Put the issues in the backlog if they are agreed upon as good ideas. Otherwise leave them for triage. +### Patterns + +- Consider similar patterns used in the product and justify in the issue when diverging + from them. +- Use appropriate [components](https://design.gitlab.com/components/overview) + and [data visualizations](https://design.gitlab.com/data-visualization/overview). + +### States + +- Account for all applicable states ([error](https://design.gitlab.com/content/error-messages), + rest, loading, focus, hover, selected, disabled). +- Account for states dependent on data size ([empty](https://design.gitlab.com/regions/empty-states), + some data, and lots of data). +- Account for states dependent on user role, user preferences, and subscription. +- Consider animations and transitions, and follow their [guidelines](https://design.gitlab.com/product-foundations/motion). + +### Visual design + +- Use recommended [colors](https://design.gitlab.com/product-foundations/colors) + and [typography](https://design.gitlab.com/product-foundations/type-fundamentals). +- Follow [layout guidelines](https://design.gitlab.com/layout/grid). +- Use existing [icons](http://gitlab-org.gitlab.io/gitlab-svgs/) and [illustrations](http://gitlab-org.gitlab.io/gitlab-svgs/illustrations) + or propose new ones according to [iconography](https://design.gitlab.com/product-foundations/iconography) + and [illustration](https://design.gitlab.com/product-foundations/illustration) + guidelines. +- _Optionally_ consider [dark mode](../../user/profile/preferences.md#dark-mode). [^1] + + [^1]: You're not required to design for [dark mode](../../user/profile/preferences.md#dark-mode) while the feature is in [alpha](https://about.gitlab.com/handbook/product/gitlab-the-product/#alpha). The [UX Foundations team](https://about.gitlab.com/direction/ecosystem/foundations/) plans to improve the dark mode in the future. Until we integrate [Pajamas](https://design.gitlab.com/) components into the product and the underlying design strategy is in place to support dark mode, we cannot guarantee that we won't introduce bugs and debt to this mode. At your discretion, evaluate the need to create dark mode patches. + +### Responsive + +- Account for resizing, collapsing, moving, or wrapping of elements across + all breakpoints (even if larger viewports are prioritized). +- Provide the same information and actions in all breakpoints. + +### Accessibility + +- Conform to level AA of the World Wide Web Consortium (W3C) [Web Content Accessibility Guidelines 2.1](https://www.w3.org/TR/WCAG21/), + according to our [statement of compliance](https://design.gitlab.com/accessibility/a11y). +- Follow accessibility [best practices](https://design.gitlab.com/accessibility/best-practices) + and [checklist](../fe_guide/accessibility.md#quick-checklist). + +### Handoff + +- Share design specifications in the related issue, preferably through a [Figma link](https://help.figma.com/hc/en-us/articles/360040531773-Share-Files-with-anyone-using-Link-Sharing#Copy_links) + link or [GitLab Designs feature](../../user/project/issues/design_management.md#the-design-management-section). + See [when you should use each tool](https://about.gitlab.com/handbook/engineering/ux/product-designer/#deliver). +- Document user flow and states (for example, using [Mermaid flowcharts in Markdown](../../user/markdown.md#mermaid)). +- Document animations and transitions. +- Document responsive behaviors. +- Document non-evident behaviors (for example, field is auto-focused). +- Document accessibility behaviors (for example, using [accessibility annotations in Figma](https://www.figma.com/file/g7QtDbfxF3pCdWiyskIr0X/Accessibility-bluelines)). +- Contribute new icons or illustrations to the [GitLab SVGs](https://gitlab.com/gitlab-org/gitlab-svgs) + project. + +### Follow-ups + +- Contribute [issues to Pajamas](https://design.gitlab.com/get-started/contribute#contribute-an-issue) + for additions or enhancements to the design system. +- Create issues with the [`~UX debt`](issue_workflow.md#technical-and-ux-debt) + label for intentional deviations from the agreed-upon UX requirements due to + time or feasibility challenges, linking back to the corresponding issue(s) or + MR(s). +- Create issues for [feature additions or enhancements](issue_workflow.md#feature-proposals) + outside the agreed-upon UX requirements to avoid scope creep. diff --git a/doc/development/contributing/issue_workflow.md b/doc/development/contributing/issue_workflow.md index 6ab51067b0d..d85d733e74c 100644 --- a/doc/development/contributing/issue_workflow.md +++ b/doc/development/contributing/issue_workflow.md @@ -342,19 +342,22 @@ To create a feature proposal, open an issue on the [issue tracker](https://gitlab.com/gitlab-org/gitlab/-/issues). In order to help track the feature proposals, we have created a -[`feature`](https://gitlab.com/gitlab-org/gitlab/-/issues?label_name=feature) label. For the time being, users that are not members -of the project cannot add labels. You can instead ask one of the [core team](https://about.gitlab.com/community/core-team/) -members to add the label ~feature to the issue or add the following +[`feature`](https://gitlab.com/gitlab-org/gitlab/-/issues?label_name=feature) label. +For the time being, users that are not members of the project cannot add labels. +You can instead ask one of the [core team](https://about.gitlab.com/community/core-team/) +members to add the label `~feature` to the issue or add the following code snippet right after your description in a new line: `~feature`. Please keep feature proposals as small and simple as possible, complex ones might be edited to make them small and simple. -Please submit Feature Proposals using the ['Feature Proposal' issue template](https://gitlab.com/gitlab-org/gitlab/-/blob/master/.gitlab/issue_templates/Feature%20proposal%20-%20detailed.md) provided on the issue tracker. +Please submit feature proposals using the ['Feature Proposal' issue template](https://gitlab.com/gitlab-org/gitlab/-/blob/master/.gitlab/issue_templates/Feature%20proposal%20-%20detailed.md) provided on the issue tracker. -For changes in the interface, it is helpful to include a mockup. Issues that add to, or change, the interface should -be given the ~"UX" label. This will allow the UX team to provide input and guidance. You may -need to ask one of the [core team](https://about.gitlab.com/community/core-team/) members to add the label, if you do not have permissions to do it by yourself. +For changes to the user interface (UI), follow our [design and UI guidelines](design.md), +and include a visual example (screenshot, wireframe, or mockup). Such issues should +be given the `~UX"` label for the Product Design team to provide input and guidance. +You may need to ask one of the [core team](https://about.gitlab.com/community/core-team/) +members to add the label, if you do not have permissions to do it by yourself. If you want to create something yourself, consider opening an issue first to discuss whether it is interesting to include this in GitLab. diff --git a/doc/development/contributing/merge_request_workflow.md b/doc/development/contributing/merge_request_workflow.md index 25561764bd6..6d3da7ef863 100644 --- a/doc/development/contributing/merge_request_workflow.md +++ b/doc/development/contributing/merge_request_workflow.md @@ -18,8 +18,8 @@ in order to ensure the work is finished before the release date. If you want to add a new feature that is not labeled, it is best to first create an issue (if there isn't one already) and leave a comment asking for it -to be marked as `Accepting Merge Requests`. Please include screenshots or -wireframes of the proposed feature if it will also change the UI. +to be marked as `Accepting merge requests`. See the [feature proposals](issue_workflow.md#feature-proposals) +section. Merge requests should be submitted to the appropriate project at GitLab.com, for example [GitLab](https://gitlab.com/gitlab-org/gitlab/-/merge_requests), diff --git a/doc/development/database/database_reviewer_guidelines.md b/doc/development/database/database_reviewer_guidelines.md index 59653c6dde3..bc18e606f21 100644 --- a/doc/development/database/database_reviewer_guidelines.md +++ b/doc/development/database/database_reviewer_guidelines.md @@ -71,6 +71,7 @@ topics and use cases. The most frequently required during database reviewing are - [Migrations style guide](../migration_style_guide.md) for creating safe SQL migrations. - [Avoiding downtime in migrations](../avoiding_downtime_in_migrations.md). - [SQL guidelines](../sql.md) for working with SQL queries. +- [Guidelines for JiHu contributions with database migrations](https://about.gitlab.com/handbook/ceo/chief-of-staff-team/jihu-support/jihu-database-change-process.html) ## How to apply to become a database maintainer diff --git a/lib/api/entities/issuable_entity.rb b/lib/api/entities/issuable_entity.rb index fd5d6c8137f..e2c674c0b8b 100644 --- a/lib/api/entities/issuable_entity.rb +++ b/lib/api/entities/issuable_entity.rb @@ -24,7 +24,7 @@ module API # entity according to the current top-level entity options, such # as the current_user. def lazy_issuable_metadata - BatchLoader.for(object).batch(key: [current_user, :issuable_metadata], replace_methods: false) do |models, loader, args| + BatchLoader.for(object).batch(key: [current_user, :issuable_metadata]) do |models, loader, args| current_user = args[:key].first issuable_metadata = Gitlab::IssuableMetadata.new(current_user, models) diff --git a/lib/api/users.rb b/lib/api/users.rb index c82bcc53cb2..1cab668a248 100644 --- a/lib/api/users.rb +++ b/lib/api/users.rb @@ -1085,7 +1085,6 @@ module API attrs = declared_params(include_missing: false) service = ::UserPreferences::UpdateService.new(current_user, attrs).execute - if service.success? present preferences, with: Entities::UserPreferences else diff --git a/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback.rb b/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback.rb index dc31f995ae0..909bf10341a 100644 --- a/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback.rb +++ b/lib/gitlab/background_migration/populate_finding_uuid_for_vulnerability_feedback.rb @@ -38,7 +38,7 @@ module Gitlab end def vulnerability_finding - BatchLoader.for(finding_key).batch(replace_methods: false) do |finding_keys, loader| + BatchLoader.for(finding_key).batch do |finding_keys, loader| project_ids = finding_keys.map { |key| key[:project_id] } categories = finding_keys.map { |key| key[:category] } fingerprints = finding_keys.map { |key| key[:project_fingerprint] } diff --git a/lib/gitlab/email/message/in_product_marketing/trial.rb b/lib/gitlab/email/message/in_product_marketing/trial.rb index 222046a3966..11a799886ab 100644 --- a/lib/gitlab/email/message/in_product_marketing/trial.rb +++ b/lib/gitlab/email/message/in_product_marketing/trial.rb @@ -15,7 +15,7 @@ module Gitlab def tagline [ - s_('InProductMarketing|Start a free trial of GitLab Ultimate – no CC required'), + s_('InProductMarketing|Start a free trial of GitLab Ultimate – no credit card required'), s_('InProductMarketing|Improve app security with a 30-day trial'), s_('InProductMarketing|Start with a GitLab Ultimate free trial') ][series] diff --git a/lib/gitlab/github_import/representation/diff_note.rb b/lib/gitlab/github_import/representation/diff_note.rb index d0584cc6255..1d6dfbead5f 100644 --- a/lib/gitlab/github_import/representation/diff_note.rb +++ b/lib/gitlab/github_import/representation/diff_note.rb @@ -11,7 +11,7 @@ module Gitlab expose_attribute :noteable_type, :noteable_id, :commit_id, :file_path, :diff_hunk, :author, :note, :created_at, :updated_at, - :github_id, :original_commit_id + :original_commit_id, :note_id NOTEABLE_ID_REGEX = %r{/pull/(?<iid>\d+)}i.freeze @@ -40,7 +40,7 @@ module Gitlab note: note.body, created_at: note.created_at, updated_at: note.updated_at, - github_id: note.id + note_id: note.id } new(hash) @@ -82,6 +82,14 @@ module Gitlab new_file: false } end + + def github_identifiers + { + note_id: note_id, + noteable_id: noteable_id, + noteable_type: noteable_type + } + end end end end diff --git a/lib/gitlab/github_import/representation/issue.rb b/lib/gitlab/github_import/representation/issue.rb index 0e04b5ad57f..db4a8188c03 100644 --- a/lib/gitlab/github_import/representation/issue.rb +++ b/lib/gitlab/github_import/representation/issue.rb @@ -25,7 +25,6 @@ module Gitlab hash = { iid: issue.number, - github_id: issue.number, title: issue.title, description: issue.body, milestone_number: issue.milestone&.number, @@ -75,6 +74,13 @@ module Gitlab def issuable_type pull_request? ? 'MergeRequest' : 'Issue' end + + def github_identifiers + { + iid: iid, + issuable_type: issuable_type + } + end end end end diff --git a/lib/gitlab/github_import/representation/lfs_object.rb b/lib/gitlab/github_import/representation/lfs_object.rb index 41723759645..18737bfcde3 100644 --- a/lib/gitlab/github_import/representation/lfs_object.rb +++ b/lib/gitlab/github_import/representation/lfs_object.rb @@ -16,8 +16,7 @@ module Gitlab new( oid: lfs_object.oid, link: lfs_object.link, - size: lfs_object.size, - github_id: lfs_object.oid + size: lfs_object.size ) end @@ -31,6 +30,12 @@ module Gitlab def initialize(attributes) @attributes = attributes end + + def github_identifiers + { + oid: oid + } + end end end end diff --git a/lib/gitlab/github_import/representation/note.rb b/lib/gitlab/github_import/representation/note.rb index 5b98ce7d5ed..bcdb1a5459b 100644 --- a/lib/gitlab/github_import/representation/note.rb +++ b/lib/gitlab/github_import/representation/note.rb @@ -10,7 +10,7 @@ module Gitlab attr_reader :attributes expose_attribute :noteable_id, :noteable_type, :author, :note, - :created_at, :updated_at, :github_id + :created_at, :updated_at, :note_id NOTEABLE_TYPE_REGEX = %r{/(?<type>(pull|issues))/(?<iid>\d+)}i.freeze @@ -42,7 +42,7 @@ module Gitlab note: note.body, created_at: note.created_at, updated_at: note.updated_at, - github_id: note.id + note_id: note.id } new(hash) @@ -64,6 +64,14 @@ module Gitlab end alias_method :issuable_type, :noteable_type + + def github_identifiers + { + note_id: note_id, + noteable_id: noteable_id, + noteable_type: noteable_type + } + end end end end diff --git a/lib/gitlab/github_import/representation/pull_request.rb b/lib/gitlab/github_import/representation/pull_request.rb index e4f54fcc833..82bcdee8b2b 100644 --- a/lib/gitlab/github_import/representation/pull_request.rb +++ b/lib/gitlab/github_import/representation/pull_request.rb @@ -25,7 +25,6 @@ module Gitlab hash = { iid: pr.number, - github_id: pr.number, title: pr.title, description: pr.body, source_branch: pr.head.ref, @@ -108,6 +107,13 @@ module Gitlab def issuable_type 'MergeRequest' end + + def github_identifiers + { + iid: iid, + issuable_type: issuable_type + } + end end end end diff --git a/lib/gitlab/github_import/representation/pull_request_review.rb b/lib/gitlab/github_import/representation/pull_request_review.rb index 08b3160fc4c..70c1e51ffdd 100644 --- a/lib/gitlab/github_import/representation/pull_request_review.rb +++ b/lib/gitlab/github_import/representation/pull_request_review.rb @@ -9,7 +9,7 @@ module Gitlab attr_reader :attributes - expose_attribute :author, :note, :review_type, :submitted_at, :github_id, :merge_request_id + expose_attribute :author, :note, :review_type, :submitted_at, :merge_request_id, :review_id def self.from_api_response(review) user = Representation::User.from_api_response(review.user) if review.user @@ -20,7 +20,7 @@ module Gitlab note: review.body, review_type: review.state, submitted_at: review.submitted_at, - github_id: review.id + review_id: review.id ) end @@ -43,6 +43,13 @@ module Gitlab def approval? review_type == 'APPROVED' end + + def github_identifiers + { + review_id: review_id, + merge_request_id: merge_request_id + } + end end end end diff --git a/lib/gitlab/github_import/representation/user.rb b/lib/gitlab/github_import/representation/user.rb index d97b90b6291..fac8920a3f2 100644 --- a/lib/gitlab/github_import/representation/user.rb +++ b/lib/gitlab/github_import/representation/user.rb @@ -17,7 +17,6 @@ module Gitlab def self.from_api_response(user) new( id: user.id, - github_id: user.id, login: user.login ) end diff --git a/lib/gitlab/pagination/keyset/iterator.rb b/lib/gitlab/pagination/keyset/iterator.rb index 14807fa37c4..f54ade6ca66 100644 --- a/lib/gitlab/pagination/keyset/iterator.rb +++ b/lib/gitlab/pagination/keyset/iterator.rb @@ -6,10 +6,11 @@ module Gitlab class Iterator UnsupportedScopeOrder = Class.new(StandardError) - def initialize(scope:, use_union_optimization: true, in_operator_optimization_options: nil) + def initialize(scope:, cursor: {}, use_union_optimization: true, in_operator_optimization_options: nil) @scope, success = Gitlab::Pagination::Keyset::SimpleOrderBuilder.build(scope) raise(UnsupportedScopeOrder, 'The order on the scope does not support keyset pagination') unless success + @cursor = cursor @order = Gitlab::Pagination::Keyset::Order.extract_keyset_order_object(scope) @use_union_optimization = in_operator_optimization_options ? false : use_union_optimization @in_operator_optimization_options = in_operator_optimization_options @@ -17,11 +18,9 @@ module Gitlab # rubocop: disable CodeReuse/ActiveRecord def each_batch(of: 1000) - cursor_attributes = {} - loop do current_scope = scope.dup - relation = order.apply_cursor_conditions(current_scope, cursor_attributes, keyset_options) + relation = order.apply_cursor_conditions(current_scope, cursor, keyset_options) relation = relation.reorder(order) unless @in_operator_optimization_options relation = relation.limit(of) @@ -30,14 +29,14 @@ module Gitlab last_record = relation.last break unless last_record - cursor_attributes = order.cursor_attributes_for_node(last_record) + @cursor = order.cursor_attributes_for_node(last_record) end end # rubocop: enable CodeReuse/ActiveRecord private - attr_reader :scope, :order + attr_reader :scope, :cursor, :order def keyset_options { diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 63f50708d23..9eb4895385a 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -16,9 +16,6 @@ msgstr "" "Content-Transfer-Encoding: 8bit\n" "Plural-Forms: nplurals=INTEGER; plural=EXPRESSION;\n" -msgid " %{name}, confirm your email address now! " -msgstr "" - msgid " %{start} to %{end}" msgstr "" @@ -1135,9 +1132,6 @@ msgstr "" msgid "(we need your current password to confirm your changes)" msgstr "" -msgid "* * * * *" -msgstr "" - msgid "+ %{amount} more" msgstr "" @@ -3587,6 +3581,9 @@ msgstr "" msgid "An error occurred while enabling Service Desk." msgstr "" +msgid "An error occurred while fetching Markdown preview" +msgstr "" + msgid "An error occurred while fetching ancestors" msgstr "" @@ -3617,9 +3614,6 @@ msgstr "" msgid "An error occurred while fetching label colors." msgstr "" -msgid "An error occurred while fetching markdown preview" -msgstr "" - msgid "An error occurred while fetching participants" msgstr "" @@ -7345,9 +7339,6 @@ msgstr "" msgid "ClusterAgents|You will need to create a token to connect to your agent" msgstr "" -msgid "ClusterAgent|This feature is only available for premium plans" -msgstr "" - msgid "ClusterAgent|User has insufficient permissions to create a token for this project" msgstr "" @@ -9999,7 +9990,7 @@ msgstr "" msgid "Custom notification events" msgstr "" -msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notificationLinkStart} notification emails%{notificationLinkEnd}." +msgid "Custom notification levels are the same as participating levels. With custom notification levels you will also receive notifications for select events. To find out more, check out %{notificationLinkStart}notification emails%{notificationLinkEnd}." msgstr "" msgid "Custom project templates" @@ -10198,10 +10189,10 @@ msgid_plural "CycleAnalytics|Showing %{subjectFilterText} and %{selectedLabelsCo msgstr[0] "" msgstr[1] "" -msgid "CycleAnalytics|Showing data for group '%{groupName}' and %{selectedProjectCount} projects from %{startDate} to %{endDate}" +msgid "CycleAnalytics|Showing data for group '%{groupName}' and %{selectedProjectCount} projects from %{createdAfter} to %{createdBefore}" msgstr "" -msgid "CycleAnalytics|Showing data for group '%{groupName}' from %{startDate} to %{endDate}" +msgid "CycleAnalytics|Showing data for group '%{groupName}' from %{createdAfter} to %{createdBefore}" msgstr "" msgid "CycleAnalytics|Stages" @@ -13366,7 +13357,7 @@ msgstr "" msgid "Error parsing CSV file. Please make sure it has" msgstr "" -msgid "Error rendering markdown preview" +msgid "Error rendering Markdown preview" msgstr "" msgid "Error saving label update." @@ -17606,7 +17597,7 @@ msgstr "" msgid "InProductMarketing|Start a GitLab Ultimate trial today in less than one minute, no credit card required." msgstr "" -msgid "InProductMarketing|Start a free trial of GitLab Ultimate – no CC required" +msgid "InProductMarketing|Start a free trial of GitLab Ultimate – no credit card required" msgstr "" msgid "InProductMarketing|Start a trial" @@ -24487,6 +24478,18 @@ msgstr "" msgid "Password was successfully updated. Please sign in again." msgstr "" +msgid "PasswordPrompt|Confirm password" +msgstr "" + +msgid "PasswordPrompt|Confirm password to continue" +msgstr "" + +msgid "PasswordPrompt|Password is required" +msgstr "" + +msgid "PasswordPrompt|Please enter your password to confirm" +msgstr "" + msgid "Passwords should be unique and not used for any other sites or services." msgstr "" @@ -24862,6 +24865,48 @@ msgstr "" msgid "PipelineSchedules|Variables" msgstr "" +msgid "PipelineSource|API" +msgstr "" + +msgid "PipelineSource|Chat" +msgstr "" + +msgid "PipelineSource|External" +msgstr "" + +msgid "PipelineSource|External Pull Request" +msgstr "" + +msgid "PipelineSource|Merge Request" +msgstr "" + +msgid "PipelineSource|On-Demand DAST Scan" +msgstr "" + +msgid "PipelineSource|On-Demand DAST Validation" +msgstr "" + +msgid "PipelineSource|Parent Pipeline" +msgstr "" + +msgid "PipelineSource|Pipeline" +msgstr "" + +msgid "PipelineSource|Push" +msgstr "" + +msgid "PipelineSource|Schedule" +msgstr "" + +msgid "PipelineSource|Trigger" +msgstr "" + +msgid "PipelineSource|Web" +msgstr "" + +msgid "PipelineSource|Web IDE" +msgstr "" + msgid "PipelineStatusTooltip|Pipeline: %{ciStatus}" msgstr "" @@ -25162,51 +25207,9 @@ msgstr "" msgid "Pipeline|Source" msgstr "" -msgid "Pipeline|Source|API" -msgstr "" - -msgid "Pipeline|Source|Chat" -msgstr "" - -msgid "Pipeline|Source|External" -msgstr "" - -msgid "Pipeline|Source|External Pull Request" -msgstr "" - -msgid "Pipeline|Source|Merge Request" -msgstr "" - -msgid "Pipeline|Source|On-Demand DAST Scan" -msgstr "" - -msgid "Pipeline|Source|On-Demand DAST Validation" -msgstr "" - -msgid "Pipeline|Source|Parent Pipeline" -msgstr "" - -msgid "Pipeline|Source|Pipeline" -msgstr "" - -msgid "Pipeline|Source|Push" -msgstr "" - -msgid "Pipeline|Source|Schedule" -msgstr "" - msgid "Pipeline|Source|Security Policy" msgstr "" -msgid "Pipeline|Source|Trigger" -msgstr "" - -msgid "Pipeline|Source|Web" -msgstr "" - -msgid "Pipeline|Source|Web IDE" -msgstr "" - msgid "Pipeline|Specify variable values to be used in this run. The values specified in %{linkStart}CI/CD settings%{linkEnd} will be used by default." msgstr "" @@ -25933,6 +25936,9 @@ msgstr "" msgid "Profiles|Enter your name, so people you know can recognize you" msgstr "" +msgid "Profiles|Enter your password to confirm the email change" +msgstr "" + msgid "Profiles|Enter your pronouns to let people know how to refer to you" msgstr "" @@ -29748,16 +29754,16 @@ msgstr "" msgid "SearchCodeResults|of %{link_to_project}" msgstr "" -msgid "SearchResults|Showing %{count} %{scope} for%{term_element}" +msgid "SearchResults|Showing %{count} %{scope} for %{term_element}" msgstr "" -msgid "SearchResults|Showing %{count} %{scope} for%{term_element} in your personal and project snippets" +msgid "SearchResults|Showing %{count} %{scope} for %{term_element} in your personal and project snippets" msgstr "" -msgid "SearchResults|Showing %{from} - %{to} of %{count} %{scope} for%{term_element}" +msgid "SearchResults|Showing %{from} - %{to} of %{count} %{scope} for %{term_element}" msgstr "" -msgid "SearchResults|Showing %{from} - %{to} of %{count} %{scope} for%{term_element} in your personal and project snippets" +msgid "SearchResults|Showing %{from} - %{to} of %{count} %{scope} for %{term_element} in your personal and project snippets" msgstr "" msgid "SearchResults|code result" @@ -31274,6 +31280,9 @@ msgstr "" msgid "Showing all issues" msgstr "" +msgid "Showing data for group '%{group_name}' from Nov 1, 2019 to Dec 31, 2019" +msgstr "" + msgid "Showing data for workflow items created in this date range. Date range cannot exceed %{maxDateRange} days." msgstr "" @@ -35150,7 +35159,7 @@ msgstr "" msgid "TimeTrackingEstimated|Est" msgstr "" -msgid "TimeTracking|%{startTag}Spent: %{endTag}%{timeSpentHumanReadable}" +msgid "TimeTracking|%{spentStart}Spent: %{spentEnd}" msgstr "" msgid "TimeTracking|Estimated:" @@ -35388,6 +35397,9 @@ msgstr "" msgid "To connect an SVN repository, check out %{svn_link}." msgstr "" +msgid "To continue using GitLab Enterprise Edition, upload the %{codeOpen}.gitlab-license%{codeClose} file or enter the license key you have received from GitLab Inc." +msgstr "" + msgid "To continue, you need to select the link in the confirmation email we sent to verify your email address. If you didn't get our email, select %{strongStart}Resend confirmation email.%{strongEnd}" msgstr "" @@ -35493,6 +35505,9 @@ msgstr "" msgid "To specify the notification level per project of a group you belong to, you need to visit project page and change notification level there." msgstr "" +msgid "To start using GitLab Enterprise Edition, upload the %{codeOpen}.gitlab-license%{codeClose} file or enter the license key you have received from GitLab Inc." +msgstr "" + msgid "To unsubscribe from this issue, please paste the following link into your browser:" msgstr "" diff --git a/spec/controllers/profiles_controller_spec.rb b/spec/controllers/profiles_controller_spec.rb index b4019643baf..4959003d788 100644 --- a/spec/controllers/profiles_controller_spec.rb +++ b/spec/controllers/profiles_controller_spec.rb @@ -3,7 +3,8 @@ require('spec_helper') RSpec.describe ProfilesController, :request_store do - let(:user) { create(:user) } + let(:password) { 'longsecret987!' } + let(:user) { create(:user, password: password) } describe 'POST update' do it 'does not update password' do @@ -23,7 +24,7 @@ RSpec.describe ProfilesController, :request_store do sign_in(user) put :update, - params: { user: { email: "john@gmail.com", name: "John" } } + params: { user: { email: "john@gmail.com", name: "John", validation_password: password } } user.reload diff --git a/spec/factories/users.rb b/spec/factories/users.rb index 04bacbe14e7..325f62f6028 100644 --- a/spec/factories/users.rb +++ b/spec/factories/users.rb @@ -139,6 +139,8 @@ FactoryBot.define do end factory :omniauth_user do + password_automatically_set { true } + transient do extern_uid { '123456' } provider { 'ldapmain' } diff --git a/spec/features/admin/admin_mode/login_spec.rb b/spec/features/admin/admin_mode/login_spec.rb index 5b2dfdb2941..c8ee6c14499 100644 --- a/spec/features/admin/admin_mode/login_spec.rb +++ b/spec/features/admin/admin_mode/login_spec.rb @@ -121,7 +121,7 @@ RSpec.describe 'Admin Mode Login' do end context 'when logging in via omniauth' do - let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: 'my-uid', provider: 'saml')} + let(:user) { create(:omniauth_user, :admin, :two_factor, extern_uid: 'my-uid', provider: 'saml', password_automatically_set: false)} let(:mock_saml_response) do File.read('spec/fixtures/authentication/saml_response.xml') end diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb index af085b63155..afb6fa26138 100644 --- a/spec/features/profiles/user_edit_profile_spec.rb +++ b/spec/features/profiles/user_edit_profile_spec.rb @@ -19,6 +19,17 @@ RSpec.describe 'User edit profile' do wait_for_requests if respond_to?(:wait_for_requests) end + def update_user_email + fill_in 'user_email', with: 'new-email@example.com' + click_button 'Update profile settings' + end + + def confirm_password(password) + fill_in 'password-confirmation', with: password + click_button 'Confirm password' + wait_for_requests if respond_to?(:wait_for_requests) + end + def visit_user visit user_path(user) wait_for_requests @@ -88,16 +99,42 @@ RSpec.describe 'User edit profile' do expect(page).to have_content('Website url is not a valid URL') end - describe 'when I change my email' do + describe 'when I change my email', :js do before do user.send_reset_password_instructions end + it 'will prompt to confirm my password' do + expect(user.reset_password_token?).to be true + + update_user_email + + expect(page).to have_selector('[data-testid="password-prompt-modal"]') + end + + context 'when prompted to confirm password' do + before do + update_user_email + end + + it 'with the correct password successfully updates' do + confirm_password(user.password) + + expect(page).to have_text("Profile was successfully updated") + end + + it 'with the incorrect password fails to update' do + confirm_password("Fake password") + + expect(page).to have_text("Invalid password") + end + end + it 'clears the reset password token' do expect(user.reset_password_token?).to be true - fill_in 'user_email', with: 'new-email@example.com' - submit_settings + update_user_email + confirm_password(user.password) user.reload expect(user.confirmation_token).not_to be_nil diff --git a/spec/features/users/login_spec.rb b/spec/features/users/login_spec.rb index afd750d02eb..2c88860aef2 100644 --- a/spec/features/users/login_spec.rb +++ b/spec/features/users/login_spec.rb @@ -874,7 +874,7 @@ RSpec.describe 'Login', :clean_gitlab_redis_shared_state do end end - context 'when the user does not have an email configured' do + context 'when the user does not have an email configured', :js do let(:user) { create(:omniauth_user, extern_uid: 'my-uid', provider: 'saml', email: 'temp-email-for-oauth-user@gitlab.localhost') } before do diff --git a/spec/finders/members_finder_spec.rb b/spec/finders/members_finder_spec.rb index d25e1b9ca4b..749e319f9c7 100644 --- a/spec/finders/members_finder_spec.rb +++ b/spec/finders/members_finder_spec.rb @@ -161,42 +161,54 @@ RSpec.describe MembersFinder, '#execute' do end context 'when :invited_groups is passed' do - subject { described_class.new(project, user2).execute(include_relations: [:inherited, :direct, :invited_groups]) } + shared_examples 'with invited_groups param' do + subject { described_class.new(project, user2).execute(include_relations: [:inherited, :direct, :invited_groups]) } - let_it_be(:linked_group) { create(:group, :public) } - let_it_be(:nested_linked_group) { create(:group, parent: linked_group) } - let_it_be(:linked_group_member) { linked_group.add_guest(user1) } - let_it_be(:nested_linked_group_member) { nested_linked_group.add_guest(user2) } + let_it_be(:linked_group) { create(:group, :public) } + let_it_be(:nested_linked_group) { create(:group, parent: linked_group) } + let_it_be(:linked_group_member) { linked_group.add_guest(user1) } + let_it_be(:nested_linked_group_member) { nested_linked_group.add_guest(user2) } - it 'includes all the invited_groups members including members inherited from ancestor groups' do - create(:project_group_link, project: project, group: nested_linked_group) + it 'includes all the invited_groups members including members inherited from ancestor groups' do + create(:project_group_link, project: project, group: nested_linked_group) - expect(subject).to contain_exactly(linked_group_member, nested_linked_group_member) - end + expect(subject).to contain_exactly(linked_group_member, nested_linked_group_member) + end - it 'includes all the invited_groups members' do - create(:project_group_link, project: project, group: linked_group) + it 'includes all the invited_groups members' do + create(:project_group_link, project: project, group: linked_group) - expect(subject).to contain_exactly(linked_group_member) - end + expect(subject).to contain_exactly(linked_group_member) + end - it 'excludes group_members not visible to the user' do - create(:project_group_link, project: project, group: linked_group) - private_linked_group = create(:group, :private) - private_linked_group.add_developer(user3) - create(:project_group_link, project: project, group: private_linked_group) + it 'excludes group_members not visible to the user' do + create(:project_group_link, project: project, group: linked_group) + private_linked_group = create(:group, :private) + private_linked_group.add_developer(user3) + create(:project_group_link, project: project, group: private_linked_group) - expect(subject).to contain_exactly(linked_group_member) + expect(subject).to contain_exactly(linked_group_member) + end + + context 'when the user is a member of invited group and ancestor groups' do + it 'returns the highest access_level for the user limited by project_group_link.group_access', :nested_groups do + create(:project_group_link, project: project, group: nested_linked_group, group_access: Gitlab::Access::REPORTER) + nested_linked_group.add_developer(user1) + + expect(subject.map(&:user)).to contain_exactly(user1, user2) + expect(subject.max_by(&:access_level).access_level).to eq(Gitlab::Access::REPORTER) + end + end end - context 'when the user is a member of invited group and ancestor groups' do - it 'returns the highest access_level for the user limited by project_group_link.group_access', :nested_groups do - create(:project_group_link, project: project, group: nested_linked_group, group_access: Gitlab::Access::REPORTER) - nested_linked_group.add_developer(user1) + it_behaves_like 'with invited_groups param' - expect(subject.map(&:user)).to contain_exactly(user1, user2) - expect(subject.max_by(&:access_level).access_level).to eq(Gitlab::Access::REPORTER) + context 'when feature flag :linear_members_finder_ancestor_scopes is disabled' do + before do + stub_feature_flags(linear_members_finder_ancestor_scopes: false) end + + it_behaves_like 'with invited_groups param' end end end diff --git a/spec/frontend/packages_and_registries/settings/project/settings/__snapshots__/utils_spec.js.snap b/spec/frontend/packages_and_registries/settings/project/settings/__snapshots__/utils_spec.js.snap index cf554717127..2719e917a9b 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/__snapshots__/utils_spec.js.snap +++ b/spec/frontend/packages_and_registries/settings/project/settings/__snapshots__/utils_spec.js.snap @@ -100,6 +100,12 @@ Array [ "variable": 30, }, Object { + "default": false, + "key": "SIXTY_DAYS", + "label": "60 days", + "variable": 60, + }, + Object { "default": true, "key": "NINETY_DAYS", "label": "90 days", diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap index 1009db46401..9938357ed24 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/__snapshots__/settings_form_spec.js.snap @@ -44,7 +44,7 @@ exports[`Settings Form Keep Regex matches snapshot 1`] = ` exports[`Settings Form OlderThan matches snapshot 1`] = ` <expiration-dropdown-stub data-testid="older-than-dropdown" - formoptions="[object Object],[object Object],[object Object],[object Object],[object Object]" + formoptions="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" label="Remove tags older than:" name="older-than" value="FOURTEEN_DAYS" diff --git a/spec/frontend/pages/profiles/password_prompt/password_prompt_modal_spec.js b/spec/frontend/pages/profiles/password_prompt/password_prompt_modal_spec.js new file mode 100644 index 00000000000..b722ac1e97b --- /dev/null +++ b/spec/frontend/pages/profiles/password_prompt/password_prompt_modal_spec.js @@ -0,0 +1,92 @@ +import { GlModal } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { + I18N_PASSWORD_PROMPT_CANCEL_BUTTON, + I18N_PASSWORD_PROMPT_CONFIRM_BUTTON, +} from '~/pages/profiles/password_prompt/constants'; +import PasswordPromptModal from '~/pages/profiles/password_prompt/password_prompt_modal.vue'; + +const createComponent = ({ props }) => { + return shallowMountExtended(PasswordPromptModal, { + propsData: { + ...props, + }, + }); +}; + +describe('Password prompt modal', () => { + let wrapper; + + const mockPassword = 'not+fake+shady+password'; + const mockEvent = { preventDefault: jest.fn() }; + const handleConfirmPasswordSpy = jest.fn(); + + const findField = () => wrapper.findByTestId('password-prompt-field'); + const findModal = () => wrapper.findComponent(GlModal); + const findConfirmBtn = () => findModal().props('actionPrimary'); + const findConfirmBtnDisabledState = () => + findModal().props('actionPrimary').attributes[2].disabled; + + const findCancelBtn = () => findModal().props('actionCancel'); + + const submitModal = () => findModal().vm.$emit('primary', mockEvent); + const setPassword = (newPw) => findField().vm.$emit('input', newPw); + + beforeEach(() => { + wrapper = createComponent({ + props: { + handleConfirmPassword: handleConfirmPasswordSpy, + }, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the password field', () => { + expect(findField().exists()).toBe(true); + }); + + it('renders the confirm button', () => { + expect(findConfirmBtn().text).toEqual(I18N_PASSWORD_PROMPT_CONFIRM_BUTTON); + }); + + it('renders the cancel button', () => { + expect(findCancelBtn().text).toEqual(I18N_PASSWORD_PROMPT_CANCEL_BUTTON); + }); + + describe('confirm button', () => { + describe('with a valid password', () => { + it('calls the `handleConfirmPassword` method when clicked', async () => { + setPassword(mockPassword); + submitModal(); + + await wrapper.vm.$nextTick(); + + expect(handleConfirmPasswordSpy).toHaveBeenCalledTimes(1); + expect(handleConfirmPasswordSpy).toHaveBeenCalledWith(mockPassword); + }); + + it('enables the confirm button', async () => { + setPassword(mockPassword); + + expect(findConfirmBtnDisabledState()).toBe(true); + + await wrapper.vm.$nextTick(); + + expect(findConfirmBtnDisabledState()).toBe(false); + }); + }); + + it('without a valid password is disabled', async () => { + setPassword(''); + + expect(findConfirmBtnDisabledState()).toBe(true); + + await wrapper.vm.$nextTick(); + + expect(findConfirmBtnDisabledState()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/vue_mr_widget/components/extensions/index_spec.js b/spec/frontend/vue_mr_widget/components/extensions/index_spec.js index 8f6fe3cd37a..63df63a9b00 100644 --- a/spec/frontend/vue_mr_widget/components/extensions/index_spec.js +++ b/spec/frontend/vue_mr_widget/components/extensions/index_spec.js @@ -1,4 +1,7 @@ -import { registerExtension, extensions } from '~/vue_merge_request_widget/components/extensions'; +import { + registerExtension, + registeredExtensions, +} from '~/vue_merge_request_widget/components/extensions'; import ExtensionBase from '~/vue_merge_request_widget/components/extensions/base.vue'; describe('MR widget extension registering', () => { @@ -14,7 +17,7 @@ describe('MR widget extension registering', () => { }, }); - expect(extensions[0]).toEqual( + expect(registeredExtensions.extensions[0]).toEqual( expect.objectContaining({ extends: ExtensionBase, name: 'Test', diff --git a/spec/frontend/vue_mr_widget/components/extensions/status_icon_spec.js b/spec/frontend/vue_mr_widget/components/extensions/status_icon_spec.js new file mode 100644 index 00000000000..84fd3c7eb21 --- /dev/null +++ b/spec/frontend/vue_mr_widget/components/extensions/status_icon_spec.js @@ -0,0 +1,36 @@ +import { GlIcon, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; + +let wrapper; + +function factory(propsData = {}) { + wrapper = shallowMount(StatusIcon, { + propsData, + }); +} + +describe('MR widget extensions status icon', () => { + afterEach(() => { + wrapper.destroy(); + }); + + it('renders loading icon', () => { + factory({ name: 'test', isLoading: true, iconName: 'status-failed' }); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + }); + + it('renders status icon', () => { + factory({ name: 'test', isLoading: false, iconName: 'status-failed' }); + + expect(wrapper.findComponent(GlIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlIcon).props('name')).toBe('status-failed'); + }); + + it('sets aria-label for status icon', () => { + factory({ name: 'test', isLoading: false, iconName: 'status-failed' }); + + expect(wrapper.findComponent(GlIcon).props('ariaLabel')).toBe('Failed test'); + }); +}); 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 c50cf7cb076..24d2b39ce87 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -1,13 +1,16 @@ +import { GlBadge, GlLink, GlIcon } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; 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 { securityReportMergeRequestDownloadPathsQueryResponse } from 'jest/vue_shared/security_reports/mock_data'; import axios from '~/lib/utils/axios_utils'; import { setFaviconOverlay } from '~/lib/utils/favicon'; import notify from '~/lib/utils/notify'; import SmartInterval from '~/smart_interval'; +import { registerExtension } from '~/vue_merge_request_widget/components/extensions'; import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants'; import eventHub from '~/vue_merge_request_widget/event_hub'; import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue'; @@ -15,6 +18,7 @@ import { stateKey } from '~/vue_merge_request_widget/stores/state_maps'; import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql'; import { faviconDataUrl, overlayDataUrl } from '../lib/utils/mock_data'; import mockData from './mock_data'; +import testExtension from './test_extension'; jest.mock('~/smart_interval'); @@ -879,4 +883,46 @@ describe('MrWidgetOptions', () => { }); }); }); + + describe('mock extension', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders collapsed data', async () => { + registerExtension(testExtension); + + await waitForPromises(); + + expect(wrapper.text()).toContain('Test extension summary count: 1'); + }); + + it('renders full data', async () => { + registerExtension(testExtension); + + await waitForPromises(); + + wrapper + .find('[data-testid="widget-extension"] [data-testid="toggle-button"]') + .trigger('click'); + + await Vue.nextTick(); + + const collapsedSection = wrapper.find('[data-testid="widget-extension-collapsed-section"]'); + expect(collapsedSection.exists()).toBe(true); + expect(collapsedSection.text()).toContain('Hello world'); + + // Renders icon in the row + expect(collapsedSection.find(GlIcon).exists()).toBe(true); + expect(collapsedSection.find(GlIcon).props('name')).toBe('status_failed_borderless'); + + // Renders badge in the row + expect(collapsedSection.find(GlBadge).exists()).toBe(true); + expect(collapsedSection.find(GlBadge).text()).toBe('Closed'); + + // Renders a link in the row + expect(collapsedSection.find(GlLink).exists()).toBe(true); + expect(collapsedSection.find(GlLink).text()).toBe('GitLab.com'); + }); + }); }); diff --git a/spec/frontend/vue_mr_widget/test_extension.js b/spec/frontend/vue_mr_widget/test_extension.js new file mode 100644 index 00000000000..7ea42e33103 --- /dev/null +++ b/spec/frontend/vue_mr_widget/test_extension.js @@ -0,0 +1,36 @@ +export default { + name: 'WidgetTestExtension', + props: ['targetProjectFullPath'], + computed: { + summary({ count, targetProjectFullPath }) { + return `Test extension summary count: ${count} & ${targetProjectFullPath}`; + }, + statusIcon({ count }) { + return count > 0 ? 'warning' : 'success'; + }, + }, + methods: { + fetchCollapsedData({ targetProjectFullPath }) { + return Promise.resolve({ targetProjectFullPath, count: 1 }); + }, + fetchFullData() { + return Promise.resolve([ + { + id: 1, + text: 'Hello world', + icon: { + name: 'status_failed_borderless', + class: 'text-danger', + }, + badge: { + text: 'Closed', + }, + link: { + href: 'https://gitlab.com', + text: 'GitLab.com', + }, + }, + ]); + }, + }, +}; diff --git a/spec/graphql/mutations/clusters/agent_tokens/create_spec.rb b/spec/graphql/mutations/clusters/agent_tokens/create_spec.rb new file mode 100644 index 00000000000..fc025c8e3d3 --- /dev/null +++ b/spec/graphql/mutations/clusters/agent_tokens/create_spec.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Clusters::AgentTokens::Create do + subject(:mutation) { described_class.new(object: nil, context: context, field: nil) } + + let_it_be(:cluster_agent) { create(:cluster_agent) } + let_it_be(:user) { create(:user) } + + let(:context) do + GraphQL::Query::Context.new( + query: OpenStruct.new(schema: nil), + values: { current_user: user }, + object: nil + ) + end + + specify { expect(described_class).to require_graphql_authorizations(:create_cluster) } + + describe '#resolve' do + let(:description) { 'new token!' } + let(:name) { 'new name' } + + subject { mutation.resolve(cluster_agent_id: cluster_agent.to_global_id, description: description, name: name) } + + context 'without token permissions' do + it 'raises an error if the resource is not accessible to the user' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'with user permissions' do + before do + cluster_agent.project.add_maintainer(user) + end + + it 'creates a new token', :aggregate_failures do + expect { subject }.to change { ::Clusters::AgentToken.count }.by(1) + expect(subject[:errors]).to eq([]) + end + + it 'returns token information', :aggregate_failures do + token = subject[:token] + + expect(subject[:secret]).not_to be_nil + expect(token.created_by_user).to eq(user) + expect(token.description).to eq(description) + expect(token.name).to eq(name) + end + + context 'invalid params' do + subject { mutation.resolve(cluster_agent_id: cluster_agent.id) } + + it 'generates an error message when id invalid', :aggregate_failures do + expect { subject }.to raise_error(::GraphQL::CoercionError) + end + end + end + end +end diff --git a/spec/graphql/mutations/clusters/agent_tokens/delete_spec.rb b/spec/graphql/mutations/clusters/agent_tokens/delete_spec.rb new file mode 100644 index 00000000000..5cdbc0f6d72 --- /dev/null +++ b/spec/graphql/mutations/clusters/agent_tokens/delete_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Clusters::AgentTokens::Delete do + let(:token) { create(:cluster_agent_token) } + let(:user) { create(:user) } + + let(:mutation) do + described_class.new( + object: double, + context: { current_user: user }, + field: double + ) + end + + it { expect(described_class.graphql_name).to eq('ClusterAgentTokenDelete') } + it { expect(described_class).to require_graphql_authorizations(:admin_cluster) } + + describe '#resolve' do + let(:global_id) { token.to_global_id } + + subject { mutation.resolve(id: global_id) } + + context 'without user permissions' do + it 'fails to delete the cluster agent', :aggregate_failures do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + expect { token.reload }.not_to raise_error + end + end + + context 'with user permissions' do + before do + token.agent.project.add_maintainer(user) + end + + it 'deletes a cluster agent', :aggregate_failures do + expect { subject }.to change { ::Clusters::AgentToken.count }.by(-1) + expect { token.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'with invalid params' do + let(:global_id) { token.id } + + it 'raises an error if the cluster agent id is invalid', :aggregate_failures do + expect { subject }.to raise_error(::GraphQL::CoercionError) + expect { token.reload }.not_to raise_error + end + end + end +end diff --git a/spec/graphql/mutations/clusters/agents/create_spec.rb b/spec/graphql/mutations/clusters/agents/create_spec.rb new file mode 100644 index 00000000000..c80b6f6cdad --- /dev/null +++ b/spec/graphql/mutations/clusters/agents/create_spec.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Clusters::Agents::Create do + subject(:mutation) { described_class.new(object: nil, context: context, field: nil) } + + let(:project) { create(:project, :public, :repository) } + let(:user) { create(:user) } + let(:context) do + GraphQL::Query::Context.new( + query: OpenStruct.new(schema: nil), + values: { current_user: user }, + object: nil + ) + end + + specify { expect(described_class).to require_graphql_authorizations(:create_cluster) } + + describe '#resolve' do + subject { mutation.resolve(project_path: project.full_path, name: 'test-agent') } + + context 'without project permissions' do + it 'raises an error if the resource is not accessible to the user' do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + end + end + + context 'with user permissions' do + before do + project.add_maintainer(user) + end + + it 'creates a new clusters_agent', :aggregate_failures do + expect { subject }.to change { ::Clusters::Agent.count }.by(1) + expect(subject[:cluster_agent].name).to eq('test-agent') + expect(subject[:errors]).to eq([]) + end + + context 'invalid params' do + subject { mutation.resolve(project_path: project.full_path, name: '@bad_name!') } + + it 'generates an error message when name is invalid', :aggregate_failures do + expect(subject[:clusters_agent]).to be_nil + expect(subject[:errors]).to eq(["Name can contain only lowercase letters, digits, and '-', but cannot start or end with '-'"]) + end + end + end + end +end diff --git a/spec/graphql/mutations/clusters/agents/delete_spec.rb b/spec/graphql/mutations/clusters/agents/delete_spec.rb new file mode 100644 index 00000000000..0aabf53391a --- /dev/null +++ b/spec/graphql/mutations/clusters/agents/delete_spec.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Mutations::Clusters::Agents::Delete do + subject(:mutation) { described_class.new(object: nil, context: context, field: nil) } + + let(:cluster_agent) { create(:cluster_agent) } + let(:project) { cluster_agent.project } + let(:user) { create(:user) } + let(:context) do + GraphQL::Query::Context.new( + query: OpenStruct.new(schema: nil), + values: { current_user: user }, + object: nil + ) + end + + specify { expect(described_class).to require_graphql_authorizations(:admin_cluster) } + + describe '#resolve' do + subject { mutation.resolve(id: cluster_agent.to_global_id) } + + context 'without user permissions' do + it 'fails to delete the cluster agent', :aggregate_failures do + expect { subject }.to raise_error(Gitlab::Graphql::Errors::ResourceNotAvailable) + expect { cluster_agent.reload }.not_to raise_error + end + end + + context 'with user permissions' do + before do + project.add_maintainer(user) + end + + it 'deletes a cluster agent', :aggregate_failures do + expect { subject }.to change { ::Clusters::Agent.count }.by(-1) + expect { cluster_agent.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'with invalid params' do + subject { mutation.resolve(id: cluster_agent.id) } + + it 'raises an error if the cluster agent id is invalid', :aggregate_failures do + expect { subject }.to raise_error(::GraphQL::CoercionError) + expect { cluster_agent.reload }.not_to raise_error + end + end + end +end diff --git a/spec/graphql/types/container_expiration_policy_older_than_enum_spec.rb b/spec/graphql/types/container_expiration_policy_older_than_enum_spec.rb index 72ab605f2e6..1989b87a28f 100644 --- a/spec/graphql/types/container_expiration_policy_older_than_enum_spec.rb +++ b/spec/graphql/types/container_expiration_policy_older_than_enum_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' RSpec.describe GitlabSchema.types['ContainerExpirationPolicyOlderThanEnum'] do - let_it_be(:expected_values) { %w[SEVEN_DAYS FOURTEEN_DAYS THIRTY_DAYS NINETY_DAYS] } + let_it_be(:expected_values) { %w[SEVEN_DAYS FOURTEEN_DAYS THIRTY_DAYS SIXTY_DAYS NINETY_DAYS] } it_behaves_like 'exposing container expiration policy option', :older_than end diff --git a/spec/helpers/container_expiration_policies_helper_spec.rb b/spec/helpers/container_expiration_policies_helper_spec.rb index 7ad3804e3a9..acb6b017d2c 100644 --- a/spec/helpers/container_expiration_policies_helper_spec.rb +++ b/spec/helpers/container_expiration_policies_helper_spec.rb @@ -40,6 +40,7 @@ RSpec.describe ContainerExpirationPoliciesHelper do { key: '7d', label: '7 days until tags are automatically removed' }, { key: '14d', label: '14 days until tags are automatically removed' }, { key: '30d', label: '30 days until tags are automatically removed' }, + { key: '60d', label: '60 days until tags are automatically removed' }, { key: '90d', label: '90 days until tags are automatically removed', default: true } ] diff --git a/spec/helpers/search_helper_spec.rb b/spec/helpers/search_helper_spec.rb index 7b2334ab79e..9e870658870 100644 --- a/spec/helpers/search_helper_spec.rb +++ b/spec/helpers/search_helper_spec.rb @@ -248,13 +248,13 @@ RSpec.describe SearchHelper do it 'uses the correct singular label' do collection = Kaminari.paginate_array([:foo]).page(1).per(10) - expect(search_entries_info(collection, scope, 'foo')).to eq("Showing 1 #{label} for<span> <code>foo</code> </span>") + expect(search_entries_info(collection, scope, 'foo')).to eq("Showing 1 #{label} for <span> <code>foo</code> </span>") end it 'uses the correct plural label' do collection = Kaminari.paginate_array([:foo] * 23).page(1).per(10) - expect(search_entries_info(collection, scope, 'foo')).to eq("Showing 1 - 10 of 23 #{label.pluralize} for<span> <code>foo</code> </span>") + expect(search_entries_info(collection, scope, 'foo')).to eq("Showing 1 - 10 of 23 #{label.pluralize} for <span> <code>foo</code> </span>") end end diff --git a/spec/lib/gitlab/github_import/representation/diff_note_spec.rb b/spec/lib/gitlab/github_import/representation/diff_note_spec.rb index 7c24cd0a5db..91479d23f43 100644 --- a/spec/lib/gitlab/github_import/representation/diff_note_spec.rb +++ b/spec/lib/gitlab/github_import/representation/diff_note_spec.rb @@ -51,7 +51,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do end it 'includes the GitHub ID' do - expect(note.github_id).to eq(1) + expect(note.note_id).to eq(1) end it 'returns the noteable type' do @@ -106,7 +106,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do 'note' => 'Hello world', 'created_at' => created_at.to_s, 'updated_at' => updated_at.to_s, - 'github_id' => 1 + 'note_id' => 1 } end @@ -124,7 +124,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do 'note' => 'Hello world', 'created_at' => created_at.to_s, 'updated_at' => updated_at.to_s, - 'github_id' => 1 + 'note_id' => 1 } note = described_class.from_json_hash(hash) @@ -154,7 +154,7 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do 'note' => 'Hello world', 'created_at' => created_at.to_s, 'updated_at' => updated_at.to_s, - 'github_id' => 1 + 'note_id' => 1 ) expect(note.diff_hash).to eq( @@ -167,4 +167,18 @@ RSpec.describe Gitlab::GithubImport::Representation::DiffNote do ) end end + + describe '#github_identifiers' do + it 'returns a hash with needed identifiers' do + github_identifiers = { + noteable_id: 42, + noteable_type: 'MergeRequest', + note_id: 1 + } + other_attributes = { something_else: '_something_else_' } + note = described_class.new(github_identifiers.merge(other_attributes)) + + expect(note.github_identifiers).to eq(github_identifiers) + end + end end diff --git a/spec/lib/gitlab/github_import/representation/issue_spec.rb b/spec/lib/gitlab/github_import/representation/issue_spec.rb index 3d306a4a3a3..f3052efea70 100644 --- a/spec/lib/gitlab/github_import/representation/issue_spec.rb +++ b/spec/lib/gitlab/github_import/representation/issue_spec.rb @@ -181,4 +181,17 @@ RSpec.describe Gitlab::GithubImport::Representation::Issue do expect(object.truncated_title).to eq('foo') end end + + describe '#github_identifiers' do + it 'returns a hash with needed identifiers' do + github_identifiers = { + iid: 42, + issuable_type: 'MergeRequest' + } + other_attributes = { pull_request: true, something_else: '_something_else_' } + issue = described_class.new(github_identifiers.merge(other_attributes)) + + expect(issue.github_identifiers).to eq(github_identifiers) + end + end end diff --git a/spec/lib/gitlab/github_import/representation/lfs_object_spec.rb b/spec/lib/gitlab/github_import/representation/lfs_object_spec.rb new file mode 100644 index 00000000000..b59ea513436 --- /dev/null +++ b/spec/lib/gitlab/github_import/representation/lfs_object_spec.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::GithubImport::Representation::LfsObject do + describe '#github_identifiers' do + it 'returns a hash with needed identifiers' do + github_identifiers = { + oid: 42 + } + other_attributes = { something_else: '_something_else_' } + lfs_object = described_class.new(github_identifiers.merge(other_attributes)) + + expect(lfs_object.github_identifiers).to eq(github_identifiers) + end + end +end diff --git a/spec/lib/gitlab/github_import/representation/note_spec.rb b/spec/lib/gitlab/github_import/representation/note_spec.rb index 112bb7eb908..97addcc1c98 100644 --- a/spec/lib/gitlab/github_import/representation/note_spec.rb +++ b/spec/lib/gitlab/github_import/representation/note_spec.rb @@ -40,8 +40,8 @@ RSpec.describe Gitlab::GithubImport::Representation::Note do expect(note.updated_at).to eq(updated_at) end - it 'includes the GitHub ID' do - expect(note.github_id).to eq(1) + it 'includes the note ID' do + expect(note.note_id).to eq(1) end end end @@ -84,7 +84,7 @@ RSpec.describe Gitlab::GithubImport::Representation::Note do 'note' => 'Hello world', 'created_at' => created_at.to_s, 'updated_at' => updated_at.to_s, - 'github_id' => 1 + 'note_id' => 1 } end @@ -98,7 +98,7 @@ RSpec.describe Gitlab::GithubImport::Representation::Note do 'note' => 'Hello world', 'created_at' => created_at.to_s, 'updated_at' => updated_at.to_s, - 'github_id' => 1 + 'note_id' => 1 } note = described_class.from_json_hash(hash) @@ -106,4 +106,18 @@ RSpec.describe Gitlab::GithubImport::Representation::Note do expect(note.author).to be_nil end end + + describe '#github_identifiers' do + it 'returns a hash with needed identifiers' do + github_identifiers = { + noteable_id: 42, + noteable_type: 'Issue', + note_id: 1 + } + other_attributes = { something_else: '_something_else_' } + note = described_class.new(github_identifiers.merge(other_attributes)) + + expect(note.github_identifiers).to eq(github_identifiers) + end + end end diff --git a/spec/lib/gitlab/github_import/representation/pull_request_review_spec.rb b/spec/lib/gitlab/github_import/representation/pull_request_review_spec.rb index cad9b13774e..f812fd85fbc 100644 --- a/spec/lib/gitlab/github_import/representation/pull_request_review_spec.rb +++ b/spec/lib/gitlab/github_import/representation/pull_request_review_spec.rb @@ -14,7 +14,7 @@ RSpec.describe Gitlab::GithubImport::Representation::PullRequestReview do expect(review.note).to eq('note') expect(review.review_type).to eq('APPROVED') expect(review.submitted_at).to eq(submitted_at) - expect(review.github_id).to eq(999) + expect(review.review_id).to eq(999) expect(review.merge_request_id).to eq(42) end end @@ -50,7 +50,7 @@ RSpec.describe Gitlab::GithubImport::Representation::PullRequestReview do describe '.from_json_hash' do let(:hash) do { - 'github_id' => 999, + 'review_id' => 999, 'merge_request_id' => 42, 'note' => 'note', 'review_type' => 'APPROVED', @@ -75,4 +75,17 @@ RSpec.describe Gitlab::GithubImport::Representation::PullRequestReview do expect(review.submitted_at).to be_nil end end + + describe '#github_identifiers' do + it 'returns a hash with needed identifiers' do + github_identifiers = { + review_id: 999, + merge_request_id: 42 + } + other_attributes = { something_else: '_something_else_' } + review = described_class.new(github_identifiers.merge(other_attributes)) + + expect(review.github_identifiers).to eq(github_identifiers) + end + end end diff --git a/spec/lib/gitlab/github_import/representation/pull_request_spec.rb b/spec/lib/gitlab/github_import/representation/pull_request_spec.rb index 27a82951b01..925dba5b5a7 100644 --- a/spec/lib/gitlab/github_import/representation/pull_request_spec.rb +++ b/spec/lib/gitlab/github_import/representation/pull_request_spec.rb @@ -288,4 +288,16 @@ RSpec.describe Gitlab::GithubImport::Representation::PullRequest do expect(object.truncated_title).to eq('foo') end end + + describe '#github_identifiers' do + it 'returns a hash with needed identifiers' do + github_identifiers = { + iid: 1 + } + other_attributes = { something_else: '_something_else_' } + pr = described_class.new(github_identifiers.merge(other_attributes)) + + expect(pr.github_identifiers).to eq(github_identifiers.merge(issuable_type: 'MergeRequest')) + end + end end diff --git a/spec/lib/gitlab/pagination/keyset/iterator_spec.rb b/spec/lib/gitlab/pagination/keyset/iterator_spec.rb index c6d34d43264..a0a2c0d6a63 100644 --- a/spec/lib/gitlab/pagination/keyset/iterator_spec.rb +++ b/spec/lib/gitlab/pagination/keyset/iterator_spec.rb @@ -32,8 +32,11 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do ]) end + let(:iterator_params) { nil } let(:scope) { project.issues.reorder(custom_reorder) } + subject(:iterator) { described_class.new(**iterator_params) } + shared_examples 'iterator examples' do describe '.each_batch' do it 'yields an ActiveRecord::Relation when a block is given' do @@ -56,6 +59,30 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do expect(count).to eq(9) end + it 'continues after the cursor' do + loaded_records = [] + cursor = nil + + # stopping the iterator after the first batch and storing the cursor + iterator.each_batch(of: 2) do |relation| # rubocop: disable Lint/UnreachableLoop + loaded_records.concat(relation.to_a) + record = loaded_records.last + + cursor = custom_reorder.cursor_attributes_for_node(record) + break + end + + expect(loaded_records).to eq(project.issues.order(custom_reorder).take(2)) + + # continuing the iteration + new_iterator = described_class.new(**iterator_params.merge(cursor: cursor)) + new_iterator.each_batch(of: 2) do |relation| + loaded_records.concat(relation.to_a) + end + + expect(loaded_records).to eq(project.issues.order(custom_reorder)) + end + it 'allows updating of the yielded relations' do time = Time.current @@ -131,13 +158,13 @@ RSpec.describe Gitlab::Pagination::Keyset::Iterator do end context 'when use_union_optimization is used' do - subject(:iterator) { described_class.new(scope: scope, use_union_optimization: true) } + let(:iterator_params) { { scope: scope, use_union_optimization: true } } include_examples 'iterator examples' end context 'when use_union_optimization is not used' do - subject(:iterator) { described_class.new(scope: scope, use_union_optimization: false) } + let(:iterator_params) { { scope: scope, use_union_optimization: false } } include_examples 'iterator examples' end diff --git a/spec/lib/gitlab/usage_data_spec.rb b/spec/lib/gitlab/usage_data_spec.rb index a70b68a181f..5bd2d6fda4b 100644 --- a/spec/lib/gitlab/usage_data_spec.rb +++ b/spec/lib/gitlab/usage_data_spec.rb @@ -662,13 +662,13 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do subject { described_class.data[:counts] } it 'gathers usage data' do - expect(subject[:projects_with_expiration_policy_enabled]).to eq 18 + expect(subject[:projects_with_expiration_policy_enabled]).to eq 19 expect(subject[:projects_with_expiration_policy_disabled]).to eq 5 expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_unset]).to eq 1 expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_1]).to eq 1 expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_5]).to eq 1 - expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_10]).to eq 12 + expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_10]).to eq 13 expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_25]).to eq 1 expect(subject[:projects_with_expiration_policy_enabled_with_keep_n_set_to_50]).to eq 1 @@ -676,9 +676,10 @@ RSpec.describe Gitlab::UsageData, :aggregate_failures do expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_7d]).to eq 1 expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_14d]).to eq 1 expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_30d]).to eq 1 + expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_60d]).to eq 1 expect(subject[:projects_with_expiration_policy_enabled_with_older_than_set_to_90d]).to eq 14 - expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_1d]).to eq 14 + expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_1d]).to eq 15 expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_7d]).to eq 1 expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_14d]).to eq 1 expect(subject[:projects_with_expiration_policy_enabled_with_cadence_set_to_1month]).to eq 1 diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb index 8864994c15f..bfe06c44839 100644 --- a/spec/models/namespace_spec.rb +++ b/spec/models/namespace_spec.rb @@ -432,9 +432,9 @@ RSpec.describe Namespace do end describe '.search' do - let_it_be(:first_group) { build(:group, name: 'my first namespace', path: 'old-path').tap(&:save!) } - let_it_be(:parent_group) { build(:group, name: 'my parent namespace', path: 'parent-path').tap(&:save!) } - let_it_be(:second_group) { build(:group, name: 'my second namespace', path: 'new-path', parent: parent_group).tap(&:save!) } + let_it_be(:first_group) { create(:group, name: 'my first namespace', path: 'old-path') } + let_it_be(:parent_group) { create(:group, name: 'my parent namespace', path: 'parent-path') } + let_it_be(:second_group) { create(:group, name: 'my second namespace', path: 'new-path', parent: parent_group) } let_it_be(:project_with_same_path) { create(:project, id: second_group.id, path: first_group.path) } it 'returns namespaces with a matching name' do diff --git a/spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb b/spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb new file mode 100644 index 00000000000..aac8eb22771 --- /dev/null +++ b/spec/requests/api/graphql/mutations/clusters/agent_tokens/agent_tokens/create_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Create a new cluster agent token' do + include GraphqlHelpers + + let_it_be(:cluster_agent) { create(:cluster_agent) } + let_it_be(:current_user) { create(:user) } + + let(:description) { 'create token' } + let(:name) { 'token name' } + let(:mutation) do + graphql_mutation( + :cluster_agent_token_create, + { cluster_agent_id: cluster_agent.to_global_id.to_s, description: description, name: name } + ) + end + + def mutation_response + graphql_mutation_response(:cluster_agent_token_create) + end + + context 'without user permissions' do + it_behaves_like 'a mutation that returns top-level errors', + errors: ["The resource that you are attempting to access does not exist "\ + "or you don't have permission to perform this action"] + + it 'does not create a token' do + expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Clusters::AgentToken, :count) + end + end + + context 'with project permissions' do + before do + cluster_agent.project.add_maintainer(current_user) + end + + it 'creates a new token', :aggregate_failures do + expect { post_graphql_mutation(mutation, current_user: current_user) }.to change { Clusters::AgentToken.count }.by(1) + expect(mutation_response['errors']).to eq([]) + end + + it 'returns token information', :aggregate_failures do + post_graphql_mutation(mutation, current_user: current_user) + + expect(mutation_response['secret']).not_to be_nil + expect(mutation_response.dig('token', 'description')).to eq(description) + expect(mutation_response.dig('token', 'name')).to eq(name) + end + end +end diff --git a/spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb b/spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb new file mode 100644 index 00000000000..c2ef2362d66 --- /dev/null +++ b/spec/requests/api/graphql/mutations/clusters/agents/create_spec.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Create a new cluster agent' do + include GraphqlHelpers + + let(:project) { create(:project, :public, :repository) } + let(:project_name) { 'agent-test' } + let(:current_user) { create(:user) } + + let(:mutation) do + graphql_mutation( + :create_cluster_agent, + { project_path: project.full_path, name: project_name } + ) + end + + def mutation_response + graphql_mutation_response(:create_cluster_agent) + end + + context 'without project permissions' do + it_behaves_like 'a mutation that returns a top-level access error' + + it 'does not create cluster agent' do + expect { post_graphql_mutation(mutation, current_user: current_user) }.not_to change(Clusters::Agent, :count) + end + end + + context 'with user permissions' do + before do + project.add_maintainer(current_user) + end + + it 'creates a new cluster agent', :aggregate_failures do + expect { post_graphql_mutation(mutation, current_user: current_user) }.to change { Clusters::Agent.count }.by(1) + expect(mutation_response.dig('clusterAgent', 'name')).to eq(project_name) + expect(mutation_response['errors']).to eq([]) + end + end +end diff --git a/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb b/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb new file mode 100644 index 00000000000..5f6822223ca --- /dev/null +++ b/spec/requests/api/graphql/mutations/clusters/agents/delete_spec.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Delete a cluster agent' do + include GraphqlHelpers + + let(:cluster_agent) { create(:cluster_agent) } + let(:project) { cluster_agent.project } + let(:current_user) { create(:user) } + + let(:mutation) do + graphql_mutation( + :cluster_agent_delete, + { id: cluster_agent.to_global_id.uri } + ) + end + + def mutation_response + graphql_mutation_response(:cluster_agent_delete) + end + + context 'without project permissions' do + it_behaves_like 'a mutation that returns top-level errors', + errors: ['The resource that you are attempting to access does not exist '\ + 'or you don\'t have permission to perform this action'] + + it 'does not delete cluster agent' do + expect { cluster_agent.reload }.not_to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'with project permissions' do + before do + project.add_maintainer(current_user) + end + + it 'deletes a cluster agent', :aggregate_failures do + expect { post_graphql_mutation(mutation, current_user: current_user) }.to change { Clusters::Agent.count }.by(-1) + expect(mutation_response['errors']).to eq([]) + end + end +end diff --git a/spec/requests/api/graphql/project/cluster_agents_spec.rb b/spec/requests/api/graphql/project/cluster_agents_spec.rb new file mode 100644 index 00000000000..dc7254dd552 --- /dev/null +++ b/spec/requests/api/graphql/project/cluster_agents_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Project.cluster_agents' do + include GraphqlHelpers + + let_it_be(:project) { create(:project, :public) } + let_it_be(:current_user) { create(:user, maintainer_projects: [project]) } + let_it_be(:agents) { create_list(:cluster_agent, 5, project: project) } + + let(:first) { var('Int') } + let(:cluster_agents_fields) { nil } + let(:project_fields) do + query_nodes(:cluster_agents, cluster_agents_fields, args: { first: first }, max_depth: 3) + end + + let(:query) do + args = { full_path: project.full_path } + + with_signature([first], graphql_query_for(:project, args, project_fields)) + end + + before do + allow(Gitlab::Kas::Client).to receive(:new).and_return(double(get_connected_agents: [])) + end + + it 'can retrieve cluster agents' do + post_graphql(query, current_user: current_user) + + expect(graphql_data_at(:project, :cluster_agents, :nodes)).to match_array( + agents.map { |agent| a_hash_including('id' => global_id_of(agent)) } + ) + end + + context 'selecting page info' do + let(:project_fields) do + query_nodes(:cluster_agents, args: { first: first }, include_pagination_info: true) + end + + it 'can paginate cluster agents' do + post_graphql(query, current_user: current_user, variables: first.with(2)) + + expect(graphql_data_at(:project, :cluster_agents, :page_info)).to include( + 'hasNextPage' => be_truthy, + 'hasPreviousPage' => be_falsey + ) + expect(graphql_data_at(:project, :cluster_agents, :nodes)).to have_attributes(size: 2) + end + end + + context 'selecting tokens' do + let_it_be(:token_1) { create(:cluster_agent_token, agent: agents.second) } + let_it_be(:token_2) { create(:cluster_agent_token, agent: agents.second, last_used_at: 3.days.ago) } + let_it_be(:token_3) { create(:cluster_agent_token, agent: agents.second, last_used_at: 2.days.ago) } + + let(:cluster_agents_fields) { [:id, query_nodes(:tokens, of: 'ClusterAgentToken')] } + + it 'can select tokens in last_used_at order' do + post_graphql(query, current_user: current_user) + + tokens = graphql_data_at(:project, :cluster_agents, :nodes, :tokens, :nodes) + + expect(tokens).to match([ + a_hash_including('id' => global_id_of(token_3)), + a_hash_including('id' => global_id_of(token_2)), + a_hash_including('id' => global_id_of(token_1)) + ]) + end + + it 'does not suffer from N+1 performance issues' do + post_graphql(query, current_user: current_user) + + expect do + post_graphql(query, current_user: current_user) + end.to issue_same_number_of_queries_as { post_graphql(query, current_user: current_user, variables: [first.with(1)]) } + end + end + + context 'selecting connections' do + let(:agent_meta) { double(version: '1', commit_id: 'abc', pod_namespace: 'namespace', pod_name: 'pod') } + let(:connected_agent) { double(agent_id: agents.first.id, connected_at: 123456, connection_id: 1, agent_meta: agent_meta) } + + let(:metadata_fields) { query_graphql_field(:metadata, {}, [:version, :commit, :pod_namespace, :pod_name], 'AgentMetadata') } + let(:cluster_agents_fields) { [:id, query_nodes(:connections, [:connection_id, :connected_at, metadata_fields])] } + + before do + allow(Gitlab::Kas::Client).to receive(:new).and_return(double(get_connected_agents: [connected_agent])) + end + + it 'can retrieve connections and agent metadata' do + post_graphql(query, current_user: current_user) + + connection = graphql_data_at(:project, :cluster_agents, :nodes, :connections, :nodes).first + + expect(connection).to include({ + 'connectionId' => connected_agent.connection_id.to_s, + 'connectedAt' => Time.at(connected_agent.connected_at), + 'metadata' => { + 'version' => agent_meta.version, + 'commit' => agent_meta.commit_id, + 'podNamespace' => agent_meta.pod_namespace, + 'podName' => agent_meta.pod_name + } + }) + end + end +end diff --git a/spec/services/clusters/agent_tokens/create_service_spec.rb b/spec/services/clusters/agent_tokens/create_service_spec.rb new file mode 100644 index 00000000000..92629af06c8 --- /dev/null +++ b/spec/services/clusters/agent_tokens/create_service_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::AgentTokens::CreateService do + subject(:service) { described_class.new(container: project, current_user: user, params: params) } + + let_it_be(:user) { create(:user) } + + let(:cluster_agent) { create(:cluster_agent) } + let(:project) { cluster_agent.project } + let(:params) { { agent_id: cluster_agent.id, description: 'token description', name: 'token name' } } + + describe '#execute' do + subject { service.execute } + + it 'does not create a new token due to user permissions' do + expect { subject }.not_to change(::Clusters::AgentToken, :count) + end + + it 'returns permission errors', :aggregate_failures do + expect(subject.status).to eq(:error) + expect(subject.message).to eq('User has insufficient permissions to create a token for this project') + end + + context 'with user permissions' do + before do + project.add_maintainer(user) + end + + it 'creates a new token' do + expect { subject }.to change { ::Clusters::AgentToken.count }.by(1) + end + + it 'returns success status', :aggregate_failures do + expect(subject.status).to eq(:success) + expect(subject.message).to be_nil + end + + it 'returns token information', :aggregate_failures do + token = subject.payload[:token] + + expect(subject.payload[:secret]).not_to be_nil + + expect(token.created_by_user).to eq(user) + expect(token.description).to eq(params[:description]) + expect(token.name).to eq(params[:name]) + end + + context 'when params are invalid' do + let(:params) { { agent_id: 'bad_id' } } + + it 'does not create a new token' do + expect { subject }.not_to change(::Clusters::AgentToken, :count) + end + + it 'returns validation errors', :aggregate_failures do + expect(subject.status).to eq(:error) + expect(subject.message).to eq(["Agent must exist", "Name can't be blank"]) + end + end + end + end +end diff --git a/spec/services/clusters/agents/create_service_spec.rb b/spec/services/clusters/agents/create_service_spec.rb new file mode 100644 index 00000000000..2b3bbcae13c --- /dev/null +++ b/spec/services/clusters/agents/create_service_spec.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Agents::CreateService do + subject(:service) { described_class.new(project, user) } + + let(:project) { create(:project, :public, :repository) } + let(:user) { create(:user) } + + describe '#execute' do + context 'without user permissions' do + it 'returns errors when user does not have permissions' do + expect(service.execute(name: 'missing-permissions')).to eq({ + status: :error, + message: 'You have insufficient permissions to create a cluster agent for this project' + }) + end + end + + context 'with user permissions' do + before do + project.add_maintainer(user) + end + + it 'creates a new clusters_agent' do + expect { service.execute(name: 'with-user') }.to change { ::Clusters::Agent.count }.by(1) + end + + it 'returns success status', :aggregate_failures do + result = service.execute(name: 'success') + + expect(result[:status]).to eq(:success) + expect(result[:message]).to be_nil + end + + it 'returns agent values', :aggregate_failures do + new_agent = service.execute(name: 'new-agent')[:cluster_agent] + + expect(new_agent.name).to eq('new-agent') + expect(new_agent.created_by_user).to eq(user) + end + + it 'generates an error message when name is invalid' do + expect(service.execute(name: '@bad_agent_name!')).to eq({ + status: :error, + message: ["Name can contain only lowercase letters, digits, and '-', but cannot start or end with '-'"] + }) + end + end + end +end diff --git a/spec/services/clusters/agents/delete_service_spec.rb b/spec/services/clusters/agents/delete_service_spec.rb new file mode 100644 index 00000000000..1d6bc9618dd --- /dev/null +++ b/spec/services/clusters/agents/delete_service_spec.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Clusters::Agents::DeleteService do + subject(:service) { described_class.new(container: project, current_user: user) } + + let(:cluster_agent) { create(:cluster_agent) } + let(:project) { cluster_agent.project } + let(:user) { create(:user) } + + describe '#execute' do + context 'without user permissions' do + it 'fails to delete when the user has no permissions', :aggregate_failures do + response = service.execute(cluster_agent) + + expect(response.status).to eq(:error) + expect(response.message).to eq('You have insufficient permissions to delete this cluster agent') + + expect { cluster_agent.reload }.not_to raise_error(ActiveRecord::RecordNotFound) + end + end + + context 'with user permissions' do + before do + project.add_maintainer(user) + end + + it 'deletes a cluster agent', :aggregate_failures do + expect { service.execute(cluster_agent) }.to change { ::Clusters::Agent.count }.by(-1) + expect { cluster_agent.reload }.to raise_error(ActiveRecord::RecordNotFound) + end + end + end +end diff --git a/spec/services/users/update_service_spec.rb b/spec/services/users/update_service_spec.rb index b30b7e6eb56..3244db4c1fb 100644 --- a/spec/services/users/update_service_spec.rb +++ b/spec/services/users/update_service_spec.rb @@ -3,7 +3,8 @@ require 'spec_helper' RSpec.describe Users::UpdateService do - let(:user) { create(:user) } + let(:password) { 'longsecret987!' } + let(:user) { create(:user, password: password, password_confirmation: password) } describe '#execute' do it 'updates time preferences' do @@ -18,7 +19,7 @@ RSpec.describe Users::UpdateService do it 'returns an error result when record cannot be updated' do result = {} expect do - result = update_user(user, { email: 'invalid' }) + result = update_user(user, { email: 'invalid', validation_password: password }) end.not_to change { user.reload.email } expect(result[:status]).to eq(:error) expect(result[:message]).to eq('Email is invalid') @@ -65,7 +66,7 @@ RSpec.describe Users::UpdateService do context 'updating canonical email' do context 'if email was changed' do subject do - update_user(user, email: 'user+extrastuff@example.com') + update_user(user, email: 'user+extrastuff@example.com', validation_password: password) end it 'calls canonicalize_email' do @@ -75,15 +76,68 @@ RSpec.describe Users::UpdateService do subject end + + context 'when check_password is true' do + def update_user(user, opts) + described_class.new(user, opts.merge(user: user)).execute(check_password: true) + end + + it 'returns error if no password confirmation was passed', :aggregate_failures do + result = {} + + expect do + result = update_user(user, { email: 'example@example.com' }) + end.not_to change { user.reload.unconfirmed_email } + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq('Invalid password') + end + + it 'returns error if wrong password confirmation was passed', :aggregate_failures do + result = {} + + expect do + result = update_user(user, { email: 'example@example.com', validation_password: 'wrongpassword' }) + end.not_to change { user.reload.unconfirmed_email } + expect(result[:status]).to eq(:error) + expect(result[:message]).to eq('Invalid password') + end + + it 'does not require password if it was automatically set', :aggregate_failures do + user.update!(password_automatically_set: true) + result = {} + + expect do + result = update_user(user, { email: 'example@example.com' }) + end.to change { user.reload.unconfirmed_email } + expect(result[:status]).to eq(:success) + end + + it 'does not require a password if the attribute changed does not require it' do + result = {} + + expect do + result = update_user(user, { job_title: 'supreme leader of the universe' }) + end.to change { user.reload.job_title } + expect(result[:status]).to eq(:success) + end + end end - context 'if email was NOT changed' do - subject do - update_user(user, job_title: 'supreme leader of the universe') + context 'when check_password is left to false' do + it 'does not require a password check', :aggregate_failures do + result = {} + expect do + result = update_user(user, { email: 'example@example.com' }) + end.to change { user.reload.unconfirmed_email } + expect(result[:status]).to eq(:success) end + end + context 'if email was NOT changed' do it 'skips update canonicalize email service call' do - expect { subject }.not_to change { user.user_canonical_email } + expect do + update_user(user, job_title: 'supreme leader of the universe') + end.not_to change { user.user_canonical_email } end end end @@ -106,7 +160,7 @@ RSpec.describe Users::UpdateService do it 'raises an error when record cannot be updated' do expect do - update_user(user, email: 'invalid') + update_user(user, email: 'invalid', validation_password: password) end.to raise_error(ActiveRecord::RecordInvalid) end diff --git a/spec/support/helpers/usage_data_helpers.rb b/spec/support/helpers/usage_data_helpers.rb index b1a9aade043..e772fd237fa 100644 --- a/spec/support/helpers/usage_data_helpers.rb +++ b/spec/support/helpers/usage_data_helpers.rb @@ -112,6 +112,7 @@ module UsageDataHelpers projects_with_expiration_policy_enabled_with_older_than_set_to_7d projects_with_expiration_policy_enabled_with_older_than_set_to_14d projects_with_expiration_policy_enabled_with_older_than_set_to_30d + projects_with_expiration_policy_enabled_with_older_than_set_to_60d projects_with_expiration_policy_enabled_with_older_than_set_to_90d projects_with_expiration_policy_enabled_with_cadence_set_to_1d projects_with_expiration_policy_enabled_with_cadence_set_to_7d diff --git a/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb b/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb index c1ac5ffebe8..b5252294b27 100644 --- a/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb +++ b/spec/workers/concerns/gitlab/github_import/object_importer_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe Gitlab::GithubImport::ObjectImporter do +RSpec.describe Gitlab::GithubImport::ObjectImporter, :aggregate_failures do let(:worker) do Class.new do def self.name @@ -26,9 +26,15 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter do let(:importer_class) { double(:importer_class, name: 'klass_name') } let(:importer_instance) { double(:importer_instance) } let(:client) { double(:client) } + let(:github_identifiers) do + { + some_id: 1, + some_type: '_some_type_' + } + end - before do - stub_const('MockRepresantation', Class.new do + let(:representation_class) do + Class.new do include Gitlab::GithubImport::Representation::ToHash include Gitlab::GithubImport::Representation::ExposeAttribute @@ -41,7 +47,20 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter do def initialize(attributes) @attributes = attributes end - end) + + def github_identifiers + { + some_id: 1, + some_type: '_some_type_' + } + end + end + end + + let(:stubbed_representation) { representation_class } + + before do + stub_const('MockRepresantation', stubbed_representation) end describe '#import', :clean_gitlab_redis_cache do @@ -64,7 +83,7 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter do expect(Gitlab::GithubImport::Logger) .to receive(:info) .with( - github_id: 1, + github_identifiers: github_identifiers, message: 'starting importer', project_id: project.id, importer: 'klass_name' @@ -73,7 +92,7 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter do expect(Gitlab::GithubImport::Logger) .to receive(:info) .with( - github_id: 1, + github_identifiers: github_identifiers, message: 'importer finished', project_id: project.id, importer: 'klass_name' @@ -101,7 +120,7 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter do expect(Gitlab::GithubImport::Logger) .to receive(:info) .with( - github_id: 1, + github_identifiers: github_identifiers, message: 'starting importer', project_id: project.id, importer: 'klass_name' @@ -125,21 +144,25 @@ RSpec.describe Gitlab::GithubImport::ObjectImporter do expect(project.import_failures.last.exception_message).to eq('some error') end - it 'logs error when representation does not have a github_id' do - expect(importer_class).not_to receive(:new) + context 'without github_identifiers defined' do + let(:stubbed_representation) { representation_class.instance_eval { undef_method :github_identifiers } } - expect(Gitlab::Import::ImportFailureService) - .to receive(:track) - .with( - project_id: project.id, - exception: a_kind_of(KeyError), - error_source: 'klass_name', - fail_import: true - ) - .and_call_original + it 'logs error when representation does not have a github_id' do + expect(importer_class).not_to receive(:new) - expect { worker.import(project, client, { 'number' => 10 }) } - .to raise_error(KeyError, 'key not found: :github_id') + expect(Gitlab::Import::ImportFailureService) + .to receive(:track) + .with( + project_id: project.id, + exception: a_kind_of(NoMethodError), + error_source: 'klass_name', + fail_import: true + ) + .and_call_original + + expect { worker.import(project, client, { 'number' => 10 }) } + .to raise_error(NoMethodError, /^undefined method `github_identifiers/) + end end end end |