diff options
80 files changed, 1119 insertions, 398 deletions
diff --git a/.gitlab/issue_templates/Acceptance Testing.md b/.gitlab/issue_templates/Acceptance Testing.md index 5a6c35f28ad..52896382554 100644 --- a/.gitlab/issue_templates/Acceptance Testing.md +++ b/.gitlab/issue_templates/Acceptance Testing.md @@ -27,7 +27,7 @@ Then leave running while monitoring and performing some testing through web, api - [ ] [Inspect logs in ELK](https://log.gitlab.net/app/kibana) - [ ] [Check for errors in GitLab Dev Sentry](https://sentry.gitlab.net/gitlab/devgitlaborg/?query=is%3Aunresolved) -## 2. Staging Trial +## 3. Staging Trial #### Check Staging Server Versions - [ ] GitLab: https://staging.gitlab.com/help diff --git a/.rubocop.yml b/.rubocop.yml index 1ae4ac47bca..4a0cd9579e6 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -326,6 +326,14 @@ RSpec/TimecopTravel: - 'ee/spec/**/*.rb' - 'qa/spec/**/*.rb' +RSpec/WebMockEnable: + Enabled: true + Include: + - 'spec/**/*.rb' + - 'ee/spec/**/*.rb' + Exclude: + - 'spec/support/webmock.rb' + Naming/PredicateName: Enabled: true Exclude: diff --git a/GITALY_SERVER_VERSION b/GITALY_SERVER_VERSION index 59470434801..b2cc516dff6 100644 --- a/GITALY_SERVER_VERSION +++ b/GITALY_SERVER_VERSION @@ -1 +1 @@ -b1516d21d7a46991c77eff2acefe042bce89ebd8 +a3f30273065e69dba8bebea04ed119b5e3f16793 @@ -163,7 +163,7 @@ gem 'asciidoctor-kroki', '~> 0.2.2', require: false gem 'rouge', '~> 3.26.0' gem 'truncato', '~> 0.7.11' gem 'bootstrap_form', '~> 4.2.0' -gem 'nokogiri', '~> 1.10.9' +gem 'nokogiri', '~> 1.11.1' gem 'escape_utils', '~> 1.1' # Calendar rendering diff --git a/Gemfile.lock b/Gemfile.lock index 2e7a7696caa..c11b85c9032 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -115,13 +115,14 @@ GEM aws-sigv4 (~> 1.1) aws-sigv4 (1.2.1) aws-eventstream (~> 1, >= 1.0.2) - azure-storage-blob (2.0.0) + azure-storage-blob (2.0.1) azure-storage-common (~> 2.0) - nokogiri (~> 1.10.4) - azure-storage-common (2.0.1) + nokogiri (~> 1.11.0.rc2) + azure-storage-common (2.0.2) faraday (~> 1.0) faraday_middleware (~> 1.0.0.rc1) - nokogiri (~> 1.10.4) + net-http-persistent (~> 4.0) + nokogiri (~> 1.11.0.rc2) babosa (1.0.2) base32 (0.3.2) batch-loader (1.4.0) @@ -228,17 +229,16 @@ GEM activerecord (>= 3.2.0, < 6.1) deprecation_toolkit (1.5.1) activesupport (>= 4.2) - derailed_benchmarks (1.7.0) + derailed_benchmarks (1.8.1) benchmark-ips (~> 2) get_process_mem (~> 0) heapy (~> 0) memory_profiler (~> 0) - mini_histogram (~> 0) + mini_histogram (>= 0.2.1) rack (>= 1) rake (> 10, < 14) ruby-statistics (>= 2.1) thor (>= 0.19, < 2) - unicode_plot (>= 0.0.4, < 1.0.0) device_detector (1.0.0) devise (4.7.3) bcrypt (~> 3.0) @@ -309,7 +309,6 @@ GEM launchy (~> 2.1) mail (~> 2.7) encryptor (3.0.0) - enumerable-statistics (2.0.1) equalizer (0.0.11) erubi (1.9.0) escape_utils (1.2.1) @@ -573,7 +572,8 @@ GEM hashie (>= 3.0) health_check (3.0.0) railties (>= 5.0) - heapy (0.1.4) + heapy (0.2.0) + thor hipchat (1.5.2) httparty mimemagic @@ -707,10 +707,10 @@ GEM mime-types-data (~> 3.2015) mime-types-data (3.2020.0512) mimemagic (0.3.5) - mini_histogram (0.1.3) + mini_histogram (0.3.1) mini_magick (4.10.1) mini_mime (1.0.2) - mini_portile2 (2.4.0) + mini_portile2 (2.5.0) minitest (5.11.3) ms_rest (0.7.6) concurrent-ruby (~> 1.0) @@ -733,14 +733,17 @@ GEM nakayoshi_fork (0.0.4) nap (1.1.0) nenv (0.3.0) + net-http-persistent (4.0.0) + connection_pool (~> 2.2) net-ldap (0.16.3) net-ntp (2.1.3) net-ssh (6.0.0) netrc (0.11.0) nio4r (2.5.4) no_proxy_fix (0.1.2) - nokogiri (1.10.10) - mini_portile2 (~> 2.4.0) + nokogiri (1.11.1) + mini_portile2 (~> 2.5.0) + racc (~> 1.4) nokogumbo (2.0.2) nokogiri (~> 1.8, >= 1.8.4) notiffany (0.1.3) @@ -875,6 +878,7 @@ GEM public_suffix (4.0.6) pyu-ruby-sasl (0.0.3.3) raabro (1.1.6) + racc (1.5.2) rack (2.2.3) rack-accept (0.4.5) rack (>= 0.4) @@ -1194,8 +1198,6 @@ GEM unf_ext unf_ext (0.0.7.7) unicode-display_width (1.7.0) - unicode_plot (0.0.4) - enumerable-statistics (>= 2.0.1) unicode_utils (1.4.0) unicorn (5.5.5) kgio (~> 2.6) @@ -1426,7 +1428,7 @@ DEPENDENCIES net-ldap (~> 0.16.3) net-ntp net-ssh (~> 6.0) - nokogiri (~> 1.10.9) + nokogiri (~> 1.11.1) oauth2 (~> 1.4) octokit (~> 4.15) oj (~> 3.10.6) diff --git a/app/assets/javascripts/api.js b/app/assets/javascripts/api.js index 569fbb9ab43..2f3a56ec046 100644 --- a/app/assets/javascripts/api.js +++ b/app/assets/javascripts/api.js @@ -13,6 +13,7 @@ const Api = { groupMilestonesPath: '/api/:version/groups/:id/milestones', subgroupsPath: '/api/:version/groups/:id/subgroups', namespacesPath: '/api/:version/namespaces.json', + groupInvitationsPath: '/api/:version/groups/:id/invitations', groupPackagesPath: '/api/:version/groups/:id/packages', projectPackagesPath: '/api/:version/projects/:id/packages', projectPackagePath: '/api/:version/projects/:id/packages/:package_id', @@ -23,6 +24,7 @@ const Api = { projectLabelsPath: '/:namespace_path/:project_path/-/labels', projectFileSchemaPath: '/:namespace_path/:project_path/-/schema/:ref/:filename', projectUsersPath: '/api/:version/projects/:id/users', + projectInvitationsPath: '/api/:version/projects/:id/invitations', projectMembersPath: '/api/:version/projects/:id/members', projectMergeRequestsPath: '/api/:version/projects/:id/merge_requests', projectMergeRequestPath: '/api/:version/projects/:id/merge_requests/:mrid', @@ -127,12 +129,18 @@ const Api = { }); }, - inviteGroupMember(id, data) { + addGroupMembersByUserId(id, data) { const url = Api.buildUrl(this.groupMembersPath).replace(':id', encodeURIComponent(id)); return axios.post(url, data); }, + inviteGroupMembersByEmail(id, data) { + const url = Api.buildUrl(this.groupInvitationsPath).replace(':id', encodeURIComponent(id)); + + return axios.post(url, data); + }, + groupMilestones(id, options) { const url = Api.buildUrl(this.groupMilestonesPath).replace(':id', encodeURIComponent(id)); @@ -217,12 +225,18 @@ const Api = { .then(({ data }) => data); }, - inviteProjectMembers(id, data) { + addProjectMembersByUserId(id, data) { const url = Api.buildUrl(this.projectMembersPath).replace(':id', encodeURIComponent(id)); return axios.post(url, data); }, + inviteProjectMembersByEmail(id, data) { + const url = Api.buildUrl(this.projectInvitationsPath).replace(':id', encodeURIComponent(id)); + + return axios.post(url, data); + }, + // Return single project project(projectPath) { const url = Api.buildUrl(Api.projectPath).replace(':id', encodeURIComponent(projectPath)); diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue index 63248a5ad48..50782781538 100644 --- a/app/assets/javascripts/boards/components/board_form.vue +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -10,6 +10,7 @@ import { fullLabelId, fullBoardId } from '../boards_util'; import BoardConfigurationOptions from './board_configuration_options.vue'; import updateBoardMutation from '../graphql/board_update.mutation.graphql'; import createBoardMutation from '../graphql/board_create.mutation.graphql'; +import destroyBoardMutation from '../graphql/board_destroy.mutation.graphql'; const boardDefaults = { id: false, @@ -95,6 +96,9 @@ export default { fullPath: { default: '', }, + rootPath: { + default: '', + }, }, data() { return { @@ -221,8 +225,13 @@ export default { this.isLoading = true; if (this.isDeleteForm) { try { - await boardsStore.deleteBoard(this.currentBoard); - visitUrl(boardsStore.rootPath); + await this.$apollo.mutate({ + mutation: destroyBoardMutation, + variables: { + id: fullBoardId(this.board.id), + }, + }); + visitUrl(this.rootPath); } catch { Flash(this.$options.i18n.deleteErrorMessage); } finally { diff --git a/app/assets/javascripts/boards/graphql/board_destroy.mutation.graphql b/app/assets/javascripts/boards/graphql/board_destroy.mutation.graphql new file mode 100644 index 00000000000..d4b928749de --- /dev/null +++ b/app/assets/javascripts/boards/graphql/board_destroy.mutation.graphql @@ -0,0 +1,7 @@ +mutation destroyBoard($id: BoardID!) { + destroyBoard(input: { id: $id }) { + board { + id + } + } +} diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 18d2c75b7e1..e978eedee7f 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -336,5 +336,6 @@ export default () => { mountMultipleBoardsSwitcher({ fullPath: $boardApp.dataset.fullPath, + rootPath: $boardApp.dataset.boardsEndpoint, }); }; diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js index 71463010898..17a12e84a37 100644 --- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js +++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js @@ -37,6 +37,7 @@ export default (params = {}) => { }, provide: { fullPath: params.fullPath, + rootPath: params.rootPath, }, render(createElement) { return createElement(BoardsSelector, { diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 3bc077be552..af00c035a91 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -753,10 +753,6 @@ const boardsStore = { return axios.get(this.state.endpoints.recentBoardsEndpoint); }, - deleteBoard({ id }) { - return axios.delete(this.generateBoardsPath(id)); - }, - setCurrentBoard(board) { this.state.currentBoard = board; }, diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index a548354f257..acaa7ae72d1 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -6,7 +6,7 @@ import { GlButtonGroup, GlButton, GlTooltipDirective, GlIcon } from '@gitlab/ui' import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; @@ -39,7 +39,7 @@ import { setUrlParams } from '../../lib/utils/url_utility'; export default { components: { UserAvatarLink, - ClipboardButton, + ModalCopyButton, TimeAgoTooltip, CommitPipelineStatus, GlButtonGroup, @@ -142,7 +142,7 @@ export default { data-testid="commit-sha-short-id" v-text="commit.short_id" /> - <clipboard-button + <modal-copy-button :text="commit.id" :title="__('Copy commit SHA')" class="input-group-text" diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue index 2b6b8b765a2..192d6e056cd 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue @@ -35,6 +35,7 @@ export default { ...mapGetters([ 'isLoading', 'isImportingAnyRepo', + 'importingRepoCount', 'hasImportableRepos', 'hasIncompatibleRepos', 'importAllCount', @@ -60,13 +61,17 @@ export default { }, importAllButtonText() { - return this.hasIncompatibleRepos - ? n__( - 'Import %d compatible repository', - 'Import %d compatible repositories', - this.importAllCount, - ) - : n__('Import %d repository', 'Import %d repositories', this.importAllCount); + if (this.isImportingAnyRepo) { + return n__('Importing %d repository', 'Importing %d repositories', this.importingRepoCount); + } + + if (this.hasIncompatibleRepos) + return n__( + 'Import %d compatible repository', + 'Import %d compatible repositories', + this.importAllCount, + ); + return n__('Import %d repository', 'Import %d repositories', this.importAllCount); }, emptyStateText() { diff --git a/app/assets/javascripts/import_entities/import_projects/store/getters.js b/app/assets/javascripts/import_entities/import_projects/store/getters.js index 8903133ea12..ef01a67ec94 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/getters.js +++ b/app/assets/javascripts/import_entities/import_projects/store/getters.js @@ -1,14 +1,10 @@ -import { STATUSES } from '../../constants'; -import { isProjectImportable, isIncompatible } from '../utils'; +import { isProjectImportable, isIncompatible, isImporting } from '../utils'; export const isLoading = (state) => state.isLoadingRepos || state.isLoadingNamespaces; -export const isImportingAnyRepo = (state) => - state.repositories.some((repo) => - [STATUSES.SCHEDULING, STATUSES.SCHEDULED, STATUSES.STARTED].includes( - repo.importedProject?.importStatus, - ), - ); +export const importingRepoCount = (state) => state.repositories.filter(isImporting).length; + +export const isImportingAnyRepo = (state) => state.repositories.some(isImporting); export const hasIncompatibleRepos = (state) => state.repositories.some(isIncompatible); diff --git a/app/assets/javascripts/import_entities/import_projects/utils.js b/app/assets/javascripts/import_entities/import_projects/utils.js index 0610117e09b..38bd529321a 100644 --- a/app/assets/javascripts/import_entities/import_projects/utils.js +++ b/app/assets/javascripts/import_entities/import_projects/utils.js @@ -11,3 +11,9 @@ export function getImportStatus(project) { export function isProjectImportable(project) { return !isIncompatible(project) && getImportStatus(project) === STATUSES.NONE; } + +export function isImporting(repo) { + return [STATUSES.SCHEDULING, STATUSES.SCHEDULED, STATUSES.STARTED].includes( + repo.importedProject?.importStatus, + ); +} diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index dbb0ed11e1b..a92289ca8c1 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -9,6 +9,7 @@ import { GlButton, GlFormInput, } from '@gitlab/ui'; +import { partition, isString } from 'lodash'; import eventHub from '../event_hub'; import { s__, __, sprintf } from '~/locale'; import Api from '~/api'; @@ -58,7 +59,7 @@ export default { visible: true, modalId: 'invite-members-modal', selectedAccessLevel: this.defaultAccessLevel, - newUsersToInvite: '', + newUsersToInvite: [], selectedDate: undefined, }; }, @@ -79,13 +80,12 @@ export default { return { onComplete: () => { this.selectedAccessLevel = this.defaultAccessLevel; - this.newUsersToInvite = ''; + this.newUsersToInvite = []; }, }; }, - postData() { + basePostData() { return { - user_id: this.newUsersToInvite, access_level: this.selectedAccessLevel, expires_at: this.selectedDate, format: 'json', @@ -101,6 +101,17 @@ export default { eventHub.$on('openModal', this.openModal); }, methods: { + partitionNewUsersToInvite() { + const [usersToInviteByEmail, usersToAddById] = partition( + this.newUsersToInvite, + (user) => isString(user.id) && user.id.includes('user-defined-token'), + ); + + return [ + usersToInviteByEmail.map((user) => user.name).join(','), + usersToAddById.map((user) => user.id).join(','), + ]; + }, openModal() { this.$root.$emit('bv::show::modal', this.modalId); }, @@ -108,7 +119,7 @@ export default { this.$root.$emit('bv::hide::modal', this.modalId); }, sendInvite() { - this.submitForm(this.postData); + this.submitForm(); this.closeModal(); }, cancelInvite() { @@ -120,15 +131,33 @@ export default { changeSelectedItem(item) { this.selectedAccessLevel = item; }, - submitForm(formData) { - if (this.isProject) { - return Api.inviteProjectMembers(this.id, formData) - .then(this.showToastMessageSuccess) - .catch(this.showToastMessageError); + submitForm() { + const [usersToInviteByEmail, usersToAddById] = this.partitionNewUsersToInvite(); + const promises = []; + + if (usersToInviteByEmail !== '') { + const apiInviteByEmail = this.isProject + ? Api.inviteProjectMembersByEmail.bind(Api) + : Api.inviteGroupMembersByEmail.bind(Api); + + promises.push(apiInviteByEmail(this.id, this.inviteByEmailPostData(usersToInviteByEmail))); } - return Api.inviteGroupMember(this.id, formData) - .then(this.showToastMessageSuccess) - .catch(this.showToastMessageError); + + if (usersToAddById !== '') { + const apiAddByUserId = this.isProject + ? Api.addProjectMembersByUserId.bind(Api) + : Api.addGroupMembersByUserId.bind(Api); + + promises.push(apiAddByUserId(this.id, this.addByUserIdPostData(usersToAddById))); + } + + Promise.all(promises).then(this.showToastMessageSuccess).catch(this.showToastMessageError); + }, + inviteByEmailPostData(usersToInviteByEmail) { + return { ...this.basePostData, email: usersToInviteByEmail }; + }, + addByUserIdPostData(usersToAddById) { + return { ...this.basePostData, user_id: usersToAddById }; }, showToastMessageSuccess() { this.$toast.show(this.$options.labels.toastMessageSuccessful, this.toastOptions); diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue index d839c089f2e..b54812dfd96 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -1,6 +1,7 @@ <script> import { debounce } from 'lodash'; -import { GlTokenSelector, GlAvatar, GlAvatarLabeled } from '@gitlab/ui'; +import { GlTokenSelector, GlAvatar, GlAvatarLabeled, GlSprintf } from '@gitlab/ui'; +import { __ } from '~/locale'; import { USER_SEARCH_DELAY } from '../constants'; import Api from '~/api'; @@ -9,6 +10,7 @@ export default { GlTokenSelector, GlAvatar, GlAvatarLabeled, + GlSprintf, }, props: { placeholder: { @@ -32,12 +34,10 @@ export default { }; }, computed: { - newUsersToInvite() { - return this.selectedTokens - .map((obj) => { - return obj.id; - }) - .join(','); + emailIsValid() { + const regex = /.+@/; + + return this.query.match(regex) !== null; }, placeholderText() { if (this.selectedTokens.length === 0) { @@ -69,7 +69,7 @@ export default { }); }, USER_SEARCH_DELAY), handleInput() { - this.$emit('input', this.newUsersToInvite); + this.$emit('input', this.selectedTokens); }, handleBlur() { this.hideDropdownWithNoItems = false; @@ -86,6 +86,9 @@ export default { }, }, queryOptions: { exclude_internal: true, active: true }, + i18n: { + inviteTextMessage: __('Invite "%{email}" by email'), + }, }; </script> @@ -94,7 +97,7 @@ export default { v-model="selectedTokens" :dropdown-items="users" :loading="loading" - :allow-user-defined-tokens="false" + :allow-user-defined-tokens="emailIsValid" :hide-dropdown-with-no-items="hideDropdownWithNoItems" :placeholder="placeholderText" :aria-labelledby="ariaLabelledby" @@ -116,5 +119,13 @@ export default { :sub-label="dropdownItem.username" /> </template> + + <template #user-defined-token-content="{ inputText: email }"> + <gl-sprintf :message="$options.i18n.inviteTextMessage"> + <template #email> + <span>{{ email }}</span> + </template> + </gl-sprintf> + </template> </gl-token-selector> </template> diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index 4c67c310e9e..74c374018de 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import { GlToast } from '@gitlab/ui'; +import { parseBoolean } from '~/lib/utils/common_utils'; import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; Vue.use(GlToast); @@ -17,6 +18,7 @@ export default function initInviteMembersModal() { createElement(InviteMembersModal, { props: { ...el.dataset, + isProject: parseBoolean(el.dataset.isProject), accessLevels: JSON.parse(el.dataset.accessLevels), }, }), diff --git a/app/helpers/projects/alert_management_helper.rb b/app/helpers/projects/alert_management_helper.rb index 997551d9659..58f1abb2818 100644 --- a/app/helpers/projects/alert_management_helper.rb +++ b/app/helpers/projects/alert_management_helper.rb @@ -34,5 +34,3 @@ module Projects::AlertManagementHelper ) end end - -Projects::AlertManagementHelper.prepend_if_ee('EE::Projects::AlertManagementHelper') diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index 8b9055ae289..efe5789e49a 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -730,7 +730,13 @@ module Ci end def any_runners_online? - project.any_runners? { |runner| runner.active? && runner.online? && runner.can_pick?(self) } + project.any_runners? do |runner| + if Feature.enabled?(:ci_build_stuck_badge_performance_experiment, project, type: :development, default_enabled: false) + runner.active? && runner.online? + else + runner.active? && runner.online? && runner.can_pick?(self) + end + end end def stuck? diff --git a/app/models/project_services/alerts_service.rb b/app/models/project_services/alerts_service.rb index 5b7d149ace1..58d507971ca 100644 --- a/app/models/project_services/alerts_service.rb +++ b/app/models/project_services/alerts_service.rb @@ -88,5 +88,3 @@ class AlertsService < Service .execute end end - -AlertsService.prepend_if_ee('EE::AlertsService') diff --git a/app/models/user.rb b/app/models/user.rb index 02092d70d20..c6be3d6a839 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -1358,6 +1358,7 @@ class User < ApplicationRecord def hook_attrs { + id: id, name: name, username: username, avatar_url: avatar_url(only_path: false), diff --git a/app/views/admin/users/_admin_notes.html.haml b/app/views/admin/users/_admin_notes.html.haml index 5d91ba1a1ca..4da70a504f7 100644 --- a/app/views/admin/users/_admin_notes.html.haml +++ b/app/views/admin/users/_admin_notes.html.haml @@ -1,7 +1,7 @@ %fieldset %legend= _('Admin notes') .form-group.row - .col-sm-2.col-form-label.text-right + .col-sm-2.col-form-label = f.label :note, s_('AdminNote|Note') .col-sm-10 = f.text_area :note, class: 'form-control' diff --git a/app/views/groups/_invite_members_modal.html.haml b/app/views/groups/_invite_members_modal.html.haml index 3aae81cef8d..bd53f73230e 100644 --- a/app/views/groups/_invite_members_modal.html.haml +++ b/app/views/groups/_invite_members_modal.html.haml @@ -1,7 +1,7 @@ - if invite_members_allowed?(group) .js-invite-members-modal{ data: { id: group.id, name: group.name, - is_project: false, + is_project: 'false', access_levels: GroupMember.access_level_roles.to_json, default_access_level: Gitlab::Access::GUEST, help_link: help_page_url('user/permissions') } } diff --git a/app/views/groups/group_members/index.html.haml b/app/views/groups/group_members/index.html.haml index f13c1f29041..9a7cfc0a573 100644 --- a/app/views/groups/group_members/index.html.haml +++ b/app/views/groups/group_members/index.html.haml @@ -51,56 +51,52 @@ %span.badge.badge-pill= @requesters.count .tab-content #tab-members.tab-pane{ class: ('active' unless invited_active) } - .card.card-without-border + - unless filtered_search_enabled + = render 'shared/members/tab_pane/header' do + = render 'shared/members/tab_pane/title' do + = html_escape(_('Members with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + = form_tag group_group_members_path(@group), method: :get, class: 'user-search-form gl-display-flex gl-md-align-items-center gl-flex-wrap gl-flex-direction-column gl-md-flex-direction-row gl-mx-n3 gl-my-n3', data: { testid: 'user-search-form' } do + .gl-px-3.gl-py-2 + .search-control-wrap.gl-relative + = render 'shared/members/search_field' + - if can_manage_members + = render 'shared/members/tab_pane/form_item' do + = label_tag '2fa', _('2FA'), class: form_item_label_css_class + = render 'shared/members/filter_2fa_dropdown' + = render 'shared/members/tab_pane/form_item' do + = label_tag :sort_by, _('Sort by'), class: form_item_label_css_class + = render 'shared/members/sort_dropdown' + .js-group-members-list{ data: group_members_list_data_attributes(@group, @members) } + .loading + .spinner.spinner-md + = paginate @members, theme: 'gitlab', params: { invited_members_page: nil, search_invited: nil } + - if @group.shared_with_group_links.any? + #tab-groups.tab-pane - unless filtered_search_enabled = render 'shared/members/tab_pane/header' do = render 'shared/members/tab_pane/title' do - = html_escape(_('Members with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - = form_tag group_group_members_path(@group), method: :get, class: 'user-search-form gl-display-flex gl-md-align-items-center gl-flex-wrap gl-flex-direction-column gl-md-flex-direction-row gl-mx-n3 gl-my-n3', data: { testid: 'user-search-form' } do - .gl-px-3.gl-py-2 - .search-control-wrap.gl-relative - = render 'shared/members/search_field' - - if can_manage_members - = render 'shared/members/tab_pane/form_item' do - = label_tag '2fa', _('2FA'), class: form_item_label_css_class - = render 'shared/members/filter_2fa_dropdown' - = render 'shared/members/tab_pane/form_item' do - = label_tag :sort_by, _('Sort by'), class: form_item_label_css_class - = render 'shared/members/sort_dropdown' - .js-group-members-list{ data: group_members_list_data_attributes(@group, @members) } + = html_escape(_('Groups with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + .js-group-linked-list{ data: linked_groups_list_data_attributes(@group) } .loading .spinner.spinner-md - = paginate @members, theme: 'gitlab', params: { invited_members_page: nil, search_invited: nil } - - if @group.shared_with_group_links.any? - #tab-groups.tab-pane - .card.card-without-border - - unless filtered_search_enabled - = render 'shared/members/tab_pane/header' do - = render 'shared/members/tab_pane/title' do - = html_escape(_('Groups with access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - .js-group-linked-list{ data: linked_groups_list_data_attributes(@group) } - .loading - .spinner.spinner-md - if show_invited_members #tab-invited-members.tab-pane{ class: ('active' if invited_active) } - .card.card-without-border - - unless filtered_search_enabled - = render 'shared/members/tab_pane/header' do - = render 'shared/members/tab_pane/title' do - = html_escape(_('Members invited to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - = form_tag group_group_members_path(@group), method: :get, class: 'user-search-form', data: { testid: 'user-search-form' } do - = render 'shared/members/search_field', name: 'search_invited' - .js-group-invited-members-list{ data: group_members_list_data_attributes(@group, @invited_members) } - .loading - .spinner.spinner-md - = paginate @invited_members, param_name: 'invited_members_page', theme: 'gitlab', params: { page: nil } + - unless filtered_search_enabled + = render 'shared/members/tab_pane/header' do + = render 'shared/members/tab_pane/title' do + = html_escape(_('Members invited to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + = form_tag group_group_members_path(@group), method: :get, class: 'user-search-form', data: { testid: 'user-search-form' } do + = render 'shared/members/search_field', name: 'search_invited' + .js-group-invited-members-list{ data: group_members_list_data_attributes(@group, @invited_members) } + .loading + .spinner.spinner-md + = paginate @invited_members, param_name: 'invited_members_page', theme: 'gitlab', params: { page: nil } - if show_access_requests #tab-access-requests.tab-pane - .card.card-without-border - - unless filtered_search_enabled - = render 'shared/members/tab_pane/header' do - = render 'shared/members/tab_pane/title' do - = html_escape(_('Users requesting access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } - .js-group-access-requests-list{ data: group_members_list_data_attributes(@group, @requesters) } - .loading - .spinner.spinner-md + - unless filtered_search_enabled + = render 'shared/members/tab_pane/header' do + = render 'shared/members/tab_pane/title' do + = html_escape(_('Users requesting access to %{strong_start}%{group_name}%{strong_end}')) % { group_name: @group.name, strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe } + .js-group-access-requests-list{ data: group_members_list_data_attributes(@group, @requesters) } + .loading + .spinner.spinner-md diff --git a/app/views/projects/_invite_members_modal.html.haml b/app/views/projects/_invite_members_modal.html.haml index ad95f39bbfa..e8f61336882 100644 --- a/app/views/projects/_invite_members_modal.html.haml +++ b/app/views/projects/_invite_members_modal.html.haml @@ -1,7 +1,7 @@ - if invite_members_allowed?(project.group) .js-invite-members-modal{ data: { id: project.id, name: project.name, - is_project: true, + is_project: 'true', access_levels: GroupMember.access_level_roles.to_json, default_access_level: Gitlab::Access::GUEST, help_link: help_page_url('user/permissions') } } diff --git a/app/views/projects/jobs/index.html.haml b/app/views/projects/jobs/index.html.haml index cd062fcf675..e14473708af 100644 --- a/app/views/projects/jobs/index.html.haml +++ b/app/views/projects/jobs/index.html.haml @@ -8,10 +8,11 @@ .nav-controls - if can?(current_user, :update_build, @project) - if !@repository.gitlab_ci_yml && !experiment_enabled?(:jobs_empty_state) - = link_to 'Get started with Pipelines', help_page_path('ci/quick_start/README'), class: 'btn gl-button btn-info js-empty-state-button' + = link_to s_('Pipelines|Get started with Pipelines'), help_page_path('ci/quick_start/README'), class: 'btn gl-button btn-info js-empty-state-button' = link_to project_ci_lint_path(@project), class: 'btn gl-button btn-default' do - %span CI lint + %span + = _('CI Lint') .content-list.builds-content-list = render "table", builds: @builds, project: @project diff --git a/changelogs/unreleased/231073-apply-gitlab-ui-button-styles-to-buttons-in-ee-app-views-projects-.yml b/changelogs/unreleased/231073-apply-gitlab-ui-button-styles-to-buttons-in-ee-app-views-projects-.yml new file mode 100644 index 00000000000..136cb180b70 --- /dev/null +++ b/changelogs/unreleased/231073-apply-gitlab-ui-button-styles-to-buttons-in-ee-app-views-projects-.yml @@ -0,0 +1,5 @@ +--- +title: Migrate GitLab UI button for Merge Request Approvals settings +merge_request: 51159 +author: George Tsiolis +type: changed diff --git a/changelogs/unreleased/238854-fix-copy-sha-in-add-previous-commits-modal-doesnt-copy.yml b/changelogs/unreleased/238854-fix-copy-sha-in-add-previous-commits-modal-doesnt-copy.yml new file mode 100644 index 00000000000..af86f3c7cf7 --- /dev/null +++ b/changelogs/unreleased/238854-fix-copy-sha-in-add-previous-commits-modal-doesnt-copy.yml @@ -0,0 +1,5 @@ +--- +title: Fix 'copy sha' in 'add previous commits' modal doesn't copy +merge_request: 50921 +author: Kev @KevSlashNull +type: fixed diff --git a/changelogs/unreleased/244274-boards-migrate-deleteboard-board_store-function-to-vuex-action.yml b/changelogs/unreleased/244274-boards-migrate-deleteboard-board_store-function-to-vuex-action.yml new file mode 100644 index 00000000000..b3f06e2294e --- /dev/null +++ b/changelogs/unreleased/244274-boards-migrate-deleteboard-board_store-function-to-vuex-action.yml @@ -0,0 +1,5 @@ +--- +title: Migrate `deleteBoard` board_store function to GraphQL mutation +merge_request: 51069 +author: +type: changed diff --git a/changelogs/unreleased/250659-allow-users-to-use-production-stage-end-event.yml b/changelogs/unreleased/250659-allow-users-to-use-production-stage-end-event.yml new file mode 100644 index 00000000000..8690ddd60de --- /dev/null +++ b/changelogs/unreleased/250659-allow-users-to-use-production-stage-end-event.yml @@ -0,0 +1,5 @@ +--- +title: Allow users to use IssueDeployedToProduction VSA event +merge_request: 51199 +author: +type: added diff --git a/changelogs/unreleased/285634-Fix-confusion-button-text-when-importing-from-github.yml b/changelogs/unreleased/285634-Fix-confusion-button-text-when-importing-from-github.yml new file mode 100644 index 00000000000..480a3a40964 --- /dev/null +++ b/changelogs/unreleased/285634-Fix-confusion-button-text-when-importing-from-github.yml @@ -0,0 +1,5 @@ +--- +title: Fix confusing button text when importing from GitHub +merge_request: 49684 +author: Kev @KevSlashNull +type: fixed diff --git a/changelogs/unreleased/add-user-id-to-user-webhook-data.yml b/changelogs/unreleased/add-user-id-to-user-webhook-data.yml new file mode 100644 index 00000000000..712d3b5457c --- /dev/null +++ b/changelogs/unreleased/add-user-id-to-user-webhook-data.yml @@ -0,0 +1,5 @@ +--- +title: Include the user id in the webhook payload +merge_request: 50287 +author: +type: added diff --git a/changelogs/unreleased/align-admin-notes-label-to-the-left.yml b/changelogs/unreleased/align-admin-notes-label-to-the-left.yml new file mode 100644 index 00000000000..26a747c942d --- /dev/null +++ b/changelogs/unreleased/align-admin-notes-label-to-the-left.yml @@ -0,0 +1,5 @@ +--- +title: Align admin notes label to the left +merge_request: 50992 +author: Kev @KevSlashNull +type: fixed diff --git a/changelogs/unreleased/chore-disable-admin-mode-remains.yml b/changelogs/unreleased/chore-disable-admin-mode-remains.yml new file mode 100644 index 00000000000..146a8b7e93b --- /dev/null +++ b/changelogs/unreleased/chore-disable-admin-mode-remains.yml @@ -0,0 +1,5 @@ +--- +title: Fully disable auto admin mode and migrate remaining specs +merge_request: 50331 +author: Diego Louzán +type: other diff --git a/changelogs/unreleased/fix-casing-of-ci-lint-on-jobs-page.yml b/changelogs/unreleased/fix-casing-of-ci-lint-on-jobs-page.yml new file mode 100644 index 00000000000..5923c0e0aea --- /dev/null +++ b/changelogs/unreleased/fix-casing-of-ci-lint-on-jobs-page.yml @@ -0,0 +1,5 @@ +--- +title: Rename button "CI lint" to "CI Lint" on jobs page +merge_request: 50987 +author: Kev @KevSlashNull +type: fixed diff --git a/changelogs/unreleased/fix-gb-ci-build-stuck-badge-performance-experiment.yml b/changelogs/unreleased/fix-gb-ci-build-stuck-badge-performance-experiment.yml new file mode 100644 index 00000000000..0f3592600c6 --- /dev/null +++ b/changelogs/unreleased/fix-gb-ci-build-stuck-badge-performance-experiment.yml @@ -0,0 +1,5 @@ +--- +title: Add build stuck badge performance experiment +merge_request: 50521 +author: +type: performance diff --git a/config/feature_flags/development/ci_build_stuck_badge_performance_experiment.yml b/config/feature_flags/development/ci_build_stuck_badge_performance_experiment.yml new file mode 100644 index 00000000000..bd426416fe8 --- /dev/null +++ b/config/feature_flags/development/ci_build_stuck_badge_performance_experiment.yml @@ -0,0 +1,8 @@ +--- +name: ci_build_stuck_badge_performance_experiment +introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/50521 +rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/295490 +milestone: '13.7' +type: development +group: group::continuous integration +default_enabled: false diff --git a/config/feature_flags/development/coverage_fuzzing_mr_widget.yml b/config/feature_flags/development/coverage_fuzzing_mr_widget.yml deleted file mode 100644 index 589b7073b22..00000000000 --- a/config/feature_flags/development/coverage_fuzzing_mr_widget.yml +++ /dev/null @@ -1,8 +0,0 @@ ---- -name: coverage_fuzzing_mr_widget -introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/43545 -rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/257839 -milestone: '13.6' -type: development -group: group::fuzz testing -default_enabled: true diff --git a/config/known_invalid_graphql_queries.yml b/config/known_invalid_graphql_queries.yml index 770366d76cf..8ea9b662aa7 100644 --- a/config/known_invalid_graphql_queries.yml +++ b/config/known_invalid_graphql_queries.yml @@ -2,3 +2,4 @@ filenames: - ee/app/assets/javascripts/on_demand_scans/graphql/dast_scan_create.mutation.graphql - ee/app/assets/javascripts/oncall_schedules/graphql/mutations/update_oncall_schedule_rotation.mutation.graphql + - ee/app/assets/javascripts/oncall_schedules/graphql/mutations/destroy_oncall_rotation.mutation.graphql diff --git a/doc/api/projects.md b/doc/api/projects.md index 0a42034ab22..a344f53a2b1 100644 --- a/doc/api/projects.md +++ b/doc/api/projects.md @@ -2184,7 +2184,7 @@ POST /projects/:id/housekeeping ## Push Rules **(STARTER)** -### Get project push rules +### Get project push rules **(STARTER)** Get the [push rules](../push_rules/push_rules.md#enabling-push-rules) of a project. @@ -2230,7 +2230,7 @@ parameters: } ``` -### Add project push rule +### Add project push rule **(STARTER)** Adds a push rule to a specified project. @@ -2238,22 +2238,22 @@ Adds a push rule to a specified project. POST /projects/:id/push_rule ``` -| Attribute | Type | Required | Description | -|-----------------------------------------------|----------------|------------------------|-------------| -| `author_email_regex` **(STARTER)** | string | **{dotted-circle}** No | All commit author emails must match this, for example `@my-company.com$`. | -| `branch_name_regex` **(STARTER)** | string | **{dotted-circle}** No | All branch names must match this, for example `(feature|hotfix)\/*`. | -| `commit_committer_check` **(PREMIUM)** | boolean | **{dotted-circle}** No | Users can only push commits to this repository that were committed with one of their own verified emails. | -| `commit_message_negative_regex` **(STARTER)** | string | **{dotted-circle}** No | No commit message is allowed to match this, for example `ssh\:\/\/`. | -| `commit_message_regex` **(STARTER)** | string | **{dotted-circle}** No | All commit messages must match this, for example `Fixed \d+\..*`. | -| `deny_delete_tag` **(STARTER)** | boolean | **{dotted-circle}** No | Deny deleting a tag. | -| `file_name_regex` **(STARTER)** | string | **{dotted-circle}** No | All committed filenames must **not** match this, for example `(jar|exe)$`. | -| `id` | integer/string | **{check-circle}** Yes | The ID of the project or NAMESPACE/PROJECT_NAME. | -| `max_file_size` **(STARTER)** | integer | **{dotted-circle}** No | Maximum file size (MB). | -| `member_check` **(STARTER)** | boolean | **{dotted-circle}** No | Restrict commits by author (email) to existing GitLab users. | -| `prevent_secrets` **(STARTER)** | boolean | **{dotted-circle}** No | GitLab rejects any files that are likely to contain secrets. | -| `reject_unsigned_commits` **(PREMIUM)** | boolean | **{dotted-circle}** No | Reject commit when it's not signed through GPG. | +| Attribute | Type | Required | Description | +|-----------------------------------------|----------------|------------------------|-------------| +| `author_email_regex` | string | **{dotted-circle}** No | All commit author emails must match this, for example `@my-company.com$`. | +| `branch_name_regex` | string | **{dotted-circle}** No | All branch names must match this, for example `(feature|hotfix)\/*`. | +| `commit_committer_check` **(PREMIUM)** | boolean | **{dotted-circle}** No | Users can only push commits to this repository that were committed with one of their own verified emails. | +| `commit_message_negative_regex` | string | **{dotted-circle}** No | No commit message is allowed to match this, for example `ssh\:\/\/`. | +| `commit_message_regex` | string | **{dotted-circle}** No | All commit messages must match this, for example `Fixed \d+\..*`. | +| `deny_delete_tag` | boolean | **{dotted-circle}** No | Deny deleting a tag. | +| `file_name_regex` | string | **{dotted-circle}** No | All committed filenames must **not** match this, for example `(jar|exe)$`. | +| `id` | integer/string | **{check-circle}** Yes | The ID of the project or NAMESPACE/PROJECT_NAME. | +| `max_file_size` | integer | **{dotted-circle}** No | Maximum file size (MB). | +| `member_check` | boolean | **{dotted-circle}** No | Restrict commits by author (email) to existing GitLab users. | +| `prevent_secrets` | boolean | **{dotted-circle}** No | GitLab rejects any files that are likely to contain secrets. | +| `reject_unsigned_commits` **(PREMIUM)** | boolean | **{dotted-circle}** No | Reject commit when it's not signed through GPG. | -### Edit project push rule +### Edit project push rule **(STARTER)** Edits a push rule for a specified project. @@ -2261,20 +2261,20 @@ Edits a push rule for a specified project. PUT /projects/:id/push_rule ``` -| Attribute | Type | Required | Description | -|-----------------------------------------------|----------------|------------------------|-------------| -| `author_email_regex` **(STARTER)** | string | **{dotted-circle}** No | All commit author emails must match this, for example `@my-company.com$`. | -| `branch_name_regex` **(STARTER)** | string | **{dotted-circle}** No | All branch names must match this, for example `(feature|hotfix)\/*`. | -| `commit_committer_check` **(PREMIUM)** | boolean | **{dotted-circle}** No | Users can only push commits to this repository that were committed with one of their own verified emails. | -| `commit_message_negative_regex` **(STARTER)** | string | **{dotted-circle}** No | No commit message is allowed to match this, for example `ssh\:\/\/`. | -| `commit_message_regex` **(STARTER)** | string | **{dotted-circle}** No | All commit messages must match this, for example `Fixed \d+\..*`. | -| `deny_delete_tag` **(STARTER)** | boolean | **{dotted-circle}** No | Deny deleting a tag. | -| `file_name_regex` **(STARTER)** | string | **{dotted-circle}** No | All committed filenames must **not** match this, for example `(jar|exe)$`. | -| `id` | integer/string | **{check-circle}** Yes | The ID of the project or NAMESPACE/PROJECT_NAME. | -| `max_file_size` **(STARTER)** | integer | **{dotted-circle}** No | Maximum file size (MB). | -| `member_check` **(STARTER)** | boolean | **{dotted-circle}** No | Restrict commits by author (email) to existing GitLab users. | -| `prevent_secrets` **(STARTER)** | boolean | **{dotted-circle}** No | GitLab rejects any files that are likely to contain secrets. | -| `reject_unsigned_commits` **(PREMIUM)** | boolean | **{dotted-circle}** No | Reject commits when they are not GPG signed. | +| Attribute | Type | Required | Description | +|-----------------------------------------|----------------|------------------------|-------------| +| `author_email_regex` | string | **{dotted-circle}** No | All commit author emails must match this, for example `@my-company.com$`. | +| `branch_name_regex` | string | **{dotted-circle}** No | All branch names must match this, for example `(feature|hotfix)\/*`. | +| `commit_committer_check` **(PREMIUM)** | boolean | **{dotted-circle}** No | Users can only push commits to this repository that were committed with one of their own verified emails. | +| `commit_message_negative_regex` | string | **{dotted-circle}** No | No commit message is allowed to match this, for example `ssh\:\/\/`. | +| `commit_message_regex` | string | **{dotted-circle}** No | All commit messages must match this, for example `Fixed \d+\..*`. | +| `deny_delete_tag` | boolean | **{dotted-circle}** No | Deny deleting a tag. | +| `file_name_regex` | string | **{dotted-circle}** No | All committed filenames must **not** match this, for example `(jar|exe)$`. | +| `id` | integer/string | **{check-circle}** Yes | The ID of the project or NAMESPACE/PROJECT_NAME. | +| `max_file_size` | integer | **{dotted-circle}** No | Maximum file size (MB). | +| `member_check` | boolean | **{dotted-circle}** No | Restrict commits by author (email) to existing GitLab users. | +| `prevent_secrets` | boolean | **{dotted-circle}** No | GitLab rejects any files that are likely to contain secrets. | +| `reject_unsigned_commits` **(PREMIUM)** | boolean | **{dotted-circle}** No | Reject commits when they are not GPG signed. | ### Delete project push rule diff --git a/doc/development/go_guide/index.md b/doc/development/go_guide/index.md index 1760942a032..68210c08a00 100644 --- a/doc/development/go_guide/index.md +++ b/doc/development/go_guide/index.md @@ -449,11 +449,10 @@ changes between minor versions can expose bugs or cause problems in our projects Once you've picked a new Go version to use, the steps to update Omnibus and CNG are: -- [Create a merge request in the CNG project](https://gitlab.com/gitlab-org/build/CNG/edit/master/ci_files/variables.yml?branch_name=update-go-version), +- [Create a merge request in the CNG project](https://gitlab.com/gitlab-org/build/CNG/-/edit/master/ci_files/variables.yml?branch_name=update-go-version), updating the `GO_VERSION` in `ci_files/variables.yml`. -- Create a merge request in the [`gitlab-omnibus-builder` project](https://gitlab.com/gitlab-org/gitlab-omnibus-builder), - updating every file in the `docker/` directory so the `GO_VERSION` is set - appropriately. [Here's an example](https://gitlab.com/gitlab-org/gitlab-omnibus-builder/-/merge_requests/125/diffs). +- [Create a merge request in the `gitlab-omnibus-builder` project](https://gitlab.com/gitlab-org/gitlab-omnibus-builder/-/edit/master/docker/VERSIONS?branch_name=update-go-version), + updating the `GO_VERSION` in `docker/VERSIONS`. - Tag a new release of `gitlab-omnibus-builder` containing the change. - [Create a merge request in the `omnibus-gitlab` project](https://gitlab.com/gitlab-org/omnibus-gitlab/edit/master/.gitlab-ci.yml?branch_name=update-gitlab-omnibus-builder-version), updating the `BUILDER_IMAGE_REVISION` to match the newly-created tag. diff --git a/doc/system_hooks/system_hooks.md b/doc/system_hooks/system_hooks.md index b3a74e62ba8..7e4d274f21b 100644 --- a/doc/system_hooks/system_hooks.md +++ b/doc/system_hooks/system_hooks.md @@ -535,9 +535,11 @@ X-Gitlab-Event: System Hook { "object_kind": "merge_request", "user": { + "id": 1, "name": "Administrator", "username": "root", - "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon" + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", + "email": "admin@example.com" }, "project": { "name": "Example", diff --git a/doc/user/group/value_stream_analytics/index.md b/doc/user/group/value_stream_analytics/index.md index 0f9afdef995..438769872c0 100644 --- a/doc/user/group/value_stream_analytics/index.md +++ b/doc/user/group/value_stream_analytics/index.md @@ -316,15 +316,6 @@ To delete a custom value stream: ![Delete value stream](img/delete_value_stream_v13.4.png "Deleting a custom value stream") -### Disabling custom value streams - -Custom value streams are enabled by default. If you have a self-managed instance, an -administrator can open a Rails console and disable them with the following command: - -```ruby -Feature.disable(:value_stream_analytics_create_multiple_value_streams) -``` - ## Days to completion chart > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/merge_requests/21631) in GitLab 12.6. diff --git a/doc/user/project/integrations/webhooks.md b/doc/user/project/integrations/webhooks.md index 309831b7fb0..9bddbd99082 100644 --- a/doc/user/project/integrations/webhooks.md +++ b/doc/user/project/integrations/webhooks.md @@ -257,6 +257,7 @@ X-Gitlab-Event: Issue Hook "object_kind": "issue", "event_type": "issue", "user": { + "id": 1, "name": "Administrator", "username": "root", "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon", @@ -420,9 +421,11 @@ X-Gitlab-Event: Note Hook { "object_kind": "note", "user": { + "id": 1, "name": "Administrator", "username": "root", - "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon", + "email": "admin@example.com" }, "project_id": 5, "project":{ @@ -500,9 +503,11 @@ X-Gitlab-Event: Note Hook { "object_kind": "note", "user": { + "id": 1, "name": "Administrator", "username": "root", - "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon", + "email": "admin@example.com" }, "project_id": 5, "project":{ @@ -627,9 +632,11 @@ X-Gitlab-Event: Note Hook { "object_kind": "note", "user": { + "id": 1, "name": "Administrator", "username": "root", - "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon", + "email": "admin@example.com" }, "project_id": 5, "project":{ @@ -733,9 +740,11 @@ X-Gitlab-Event: Note Hook { "object_kind": "note", "user": { + "id": 1, "name": "Administrator", "username": "root", - "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon", + "email": "admin@example.com" }, "project_id": 5, "project":{ @@ -809,9 +818,11 @@ X-Gitlab-Event: Merge Request Hook { "object_kind": "merge_request", "user": { + "id": 1, "name": "Administrator", "username": "root", - "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon" + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=40\u0026d=identicon", + "email": "admin@example.com" }, "project": { "id": 1, @@ -970,9 +981,11 @@ X-Gitlab-Event: Wiki Page Hook { "object_kind": "wiki_page", "user": { + "id": 1, "name": "Administrator", "username": "root", - "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon" + "avatar_url": "http://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon", + "email": "admin@example.com" }, "project": { "id": 1, @@ -1061,6 +1074,7 @@ X-Gitlab-Event: Pipeline Hook "url": "http://192.168.64.1:3005/gitlab-org/gitlab-test/merge_requests/1" }, "user":{ + "id": 1, "name": "Administrator", "username": "root", "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon", @@ -1102,9 +1116,11 @@ X-Gitlab-Event: Pipeline Hook "manual": true, "allow_failure": false, "user":{ + "id": 1, "name": "Administrator", "username": "root", - "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon", + "email": "admin@example.com" }, "runner": null, "artifacts_file":{ @@ -1124,9 +1140,11 @@ X-Gitlab-Event: Pipeline Hook "manual": false, "allow_failure": false, "user":{ + "id": 1, "name": "Administrator", "username": "root", - "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon", + "email": "admin@example.com" }, "runner": { "id":380987, @@ -1151,9 +1169,11 @@ X-Gitlab-Event: Pipeline Hook "manual": false, "allow_failure": false, "user":{ + "id": 1, "name": "Administrator", "username": "root", - "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon", + "email": "admin@example.com" }, "runner": { "id":380987, @@ -1178,9 +1198,11 @@ X-Gitlab-Event: Pipeline Hook "manual": false, "allow_failure": false, "user":{ + "id": 1, "name": "Administrator", "username": "root", - "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon", + "email": "admin@example.com" }, "runner": { "id":380987, @@ -1205,9 +1227,11 @@ X-Gitlab-Event: Pipeline Hook "manual": false, "allow_failure": false, "user":{ + "id": 1, "name": "Administrator", "username": "root", - "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon", + "email": "admin@example.com" }, "runner": null, "artifacts_file":{ @@ -1254,7 +1278,8 @@ X-Gitlab-Event: Job Hook "id": 3, "name": "User", "email": "user@gitlab.com", - "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon" + "avatar_url": "http://www.gravatar.com/avatar/e32bd13e2add097461cb96824b7a829c?s=80\u0026d=identicon", + "email": "admin@example.com" }, "commit": { "id": 2366, @@ -1330,6 +1355,7 @@ X-Gitlab-Event: Deployment Hook }, "short_sha": "279484c0", "user": { + "id": 1, "name": "Administrator", "username": "root", "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", @@ -1472,6 +1498,7 @@ X-Gitlab-Event: Feature Flag Hook "http_url":"http://example.com/gitlabhq/gitlab-test.git" }, "user": { + "id": 1, "name": "Administrator", "username": "root", "avatar_url": "https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon", diff --git a/doc/user/project/service_desk.md b/doc/user/project/service_desk.md index 09b574f82a5..6158b41559a 100644 --- a/doc/user/project/service_desk.md +++ b/doc/user/project/service_desk.md @@ -131,6 +131,12 @@ use these placeholders in the email: You can customize the email display name. Emails sent from Service Desk have this name in the `From` header. The default display name is `GitLab Support Bot`. +To edit the custom email display name: + +1. In a project, go to **Settings > General > Service Desk**. +1. Enter a new name in **Email display name**. +1. Select **Save Changes**. + ### Using custom email address > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/2201) in [GitLab Premium](https://about.gitlab.com/pricing/) 13.0. diff --git a/lib/gitlab/analytics/cycle_analytics/stage_events.rb b/lib/gitlab/analytics/cycle_analytics/stage_events.rb index 39dc706dff5..27fc8bd9a1a 100644 --- a/lib/gitlab/analytics/cycle_analytics/stage_events.rb +++ b/lib/gitlab/analytics/cycle_analytics/stage_events.rb @@ -11,6 +11,7 @@ module Gitlab ENUM_MAPPING = { StageEvents::IssueCreated => 1, StageEvents::IssueFirstMentionedInCommit => 2, + StageEvents::IssueDeployedToProduction => 3, StageEvents::MergeRequestCreated => 100, StageEvents::MergeRequestFirstDeployedToProduction => 101, StageEvents::MergeRequestLastBuildFinished => 102, @@ -18,8 +19,7 @@ module Gitlab StageEvents::MergeRequestMerged => 104, StageEvents::CodeStageStart => 1_000, StageEvents::IssueStageEnd => 1_001, - StageEvents::PlanStageStart => 1_002, - StageEvents::ProductionStageEnd => 1_003 + StageEvents::PlanStageStart => 1_002 }.freeze EVENTS = ENUM_MAPPING.keys.freeze @@ -27,8 +27,7 @@ module Gitlab INTERNAL_EVENTS = [ StageEvents::CodeStageStart, StageEvents::IssueStageEnd, - StageEvents::PlanStageStart, - StageEvents::ProductionStageEnd + StageEvents::PlanStageStart ].freeze # Defines which start_event and end_event pairs are allowed @@ -41,7 +40,7 @@ module Gitlab ], StageEvents::IssueCreated => [ StageEvents::IssueStageEnd, - StageEvents::ProductionStageEnd + StageEvents::IssueDeployedToProduction ], StageEvents::MergeRequestCreated => [ StageEvents::MergeRequestMerged diff --git a/lib/gitlab/analytics/cycle_analytics/stage_events/production_stage_end.rb b/lib/gitlab/analytics/cycle_analytics/stage_events/issue_deployed_to_production.rb index b778364a917..3e93e60e686 100644 --- a/lib/gitlab/analytics/cycle_analytics/stage_events/production_stage_end.rb +++ b/lib/gitlab/analytics/cycle_analytics/stage_events/issue_deployed_to_production.rb @@ -4,13 +4,13 @@ module Gitlab module Analytics module CycleAnalytics module StageEvents - class ProductionStageEnd < StageEvent + class IssueDeployedToProduction < StageEvent def self.name _("Issue first deployed to production") end def self.identifier - :production_stage_end + :issue_deployed_to_production end def object_type diff --git a/locale/gitlab.pot b/locale/gitlab.pot index bba8eed23ec..c71c1cf7618 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -14701,6 +14701,11 @@ msgstr "" msgid "Imported requirements" msgstr "" +msgid "Importing %d repository" +msgid_plural "Importing %d repositories" +msgstr[0] "" +msgstr[1] "" + msgid "Improve Merge Requests and customer support with GitLab Enterprise Edition." msgstr "" @@ -15348,6 +15353,9 @@ msgstr "" msgid "Invite" msgstr "" +msgid "Invite \"%{email}\" by email" +msgstr "" + msgid "Invite \"%{trimmed}\" by email" msgstr "" @@ -19526,6 +19534,9 @@ msgstr "" msgid "OnCallSchedules|Add schedule" msgstr "" +msgid "OnCallSchedules|Are you sure you want to delete the \"%{deleteRotation}\" rotation? This action cannot be undone." +msgstr "" + msgid "OnCallSchedules|Are you sure you want to delete the \"%{deleteSchedule}\" schedule? This action cannot be undone." msgstr "" @@ -19592,6 +19603,9 @@ msgstr "" msgid "OnCallSchedules|Successfully edited your rotation" msgstr "" +msgid "OnCallSchedules|The rotation could not be deleted. Please try again." +msgstr "" + msgid "OnCallSchedules|The rotation could not be updated. Please try again." msgstr "" diff --git a/qa/Gemfile b/qa/Gemfile index fa8fd40d5bb..da45ba3b955 100644 --- a/qa/Gemfile +++ b/qa/Gemfile @@ -9,7 +9,7 @@ gem 'rspec', '~> 3.7' gem 'selenium-webdriver', '~> 3.12' gem 'airborne', '~> 0.3.4' gem 'rest-client', '~> 2.1.0' -gem 'nokogiri', '~> 1.10.9' +gem 'nokogiri', '~> 1.11.1' gem 'rspec-retry', '~> 0.6.1' gem 'rspec_junit_formatter', '~> 0.4.1' gem 'faker', '~> 1.6', '>= 1.6.6' diff --git a/qa/Gemfile.lock b/qa/Gemfile.lock index 2a5cd41a03c..3b532d90526 100644 --- a/qa/Gemfile.lock +++ b/qa/Gemfile.lock @@ -63,11 +63,12 @@ GEM mime-types-data (~> 3.2015) mime-types-data (3.2020.0425) mini_mime (1.0.2) - mini_portile2 (2.4.0) + mini_portile2 (2.5.0) minitest (5.14.2) netrc (0.11.0) - nokogiri (1.10.9) - mini_portile2 (~> 2.4.0) + nokogiri (1.11.1) + mini_portile2 (~> 2.5.0) + racc (~> 1.4) parallel (1.19.2) parallel_tests (2.29.0) parallel @@ -85,6 +86,7 @@ GEM byebug (~> 9.1) pry (~> 0.10) public_suffix (4.0.1) + racc (1.5.2) rack (2.2.3) rack-test (1.1.0) rack (>= 1.0, < 3) @@ -155,7 +157,7 @@ DEPENDENCIES faker (~> 1.6, >= 1.6.6) gitlab-qa knapsack (~> 1.17) - nokogiri (~> 1.10.9) + nokogiri (~> 1.11.1) parallel (~> 1.19) parallel_tests (~> 2.29) pry-byebug (~> 3.5.1) diff --git a/qa/qa/specs/features/browser_ui/6_release/pages/pages_pipeline_spec.rb b/qa/qa/specs/features/browser_ui/6_release/pages/pages_pipeline_spec.rb index 3b8cf97c3e8..4ac9f6884d1 100644 --- a/qa/qa/specs/features/browser_ui/6_release/pages/pages_pipeline_spec.rb +++ b/qa/qa/specs/features/browser_ui/6_release/pages/pages_pipeline_spec.rb @@ -30,7 +30,7 @@ module QA pipeline.visit! end - it 'runs a Pages-specific pipeline' do + it 'runs a Pages-specific pipeline', quarantine: 'https://gitlab.com/gitlab-org/gitlab/-/issues/296937' do Page::Project::Pipeline::Show.perform do |show| expect(show).to have_job(:pages) show.click_job(:pages) diff --git a/rubocop/cop/rspec/web_mock_enable.rb b/rubocop/cop/rspec/web_mock_enable.rb new file mode 100644 index 00000000000..bcf7f95dbbd --- /dev/null +++ b/rubocop/cop/rspec/web_mock_enable.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module RuboCop + module Cop + module RSpec + class WebMockEnable < RuboCop::Cop::Cop + # This cop checks for `WebMock.disable_net_connect!` usage in specs and + # replaces it with `webmock_enable!` + # + # @example + # + # # bad + # WebMock.disable_net_connect! + # WebMock.disable_net_connect!(allow_localhost: true) + # + # # good + # webmock_enable! + + MESSAGE = 'Use webmock_enable! instead of calling WebMock.disable_net_connect! directly.' + + def_node_matcher :webmock_disable_net_connect?, <<~PATTERN + (send (const nil? :WebMock) :disable_net_connect! ...) + PATTERN + + def on_send(node) + if webmock_disable_net_connect?(node) + add_offense(node, location: :expression, message: MESSAGE) + end + end + + def autocorrect(node) + lambda do |corrector| + corrector.replace(node, 'webmock_enable!') + end + end + end + end + end +end diff --git a/spec/features/projects/jobs/user_browses_jobs_spec.rb b/spec/features/projects/jobs/user_browses_jobs_spec.rb index c768b0e281c..5abebf2320e 100644 --- a/spec/features/projects/jobs/user_browses_jobs_spec.rb +++ b/spec/features/projects/jobs/user_browses_jobs_spec.rb @@ -26,7 +26,7 @@ RSpec.describe 'User browses jobs' do it 'shows the "CI Lint" button' do page.within('.nav-controls') do - ci_lint_tool_link = page.find_link('CI lint') + ci_lint_tool_link = page.find_link('CI Lint') expect(ci_lint_tool_link[:href]).to end_with(project_ci_lint_path(project)) end diff --git a/spec/finders/cluster_ancestors_finder_spec.rb b/spec/finders/cluster_ancestors_finder_spec.rb index ea1dbea4cfe..a54809801b5 100644 --- a/spec/finders/cluster_ancestors_finder_spec.rb +++ b/spec/finders/cluster_ancestors_finder_spec.rb @@ -83,8 +83,16 @@ RSpec.describe ClusterAncestorsFinder, '#execute' do let(:clusterable) { Clusters::Instance.new } let(:user) { create(:admin) } - it 'returns the list of instance clusters' do - is_expected.to eq([instance_cluster]) + context 'when admin mode is enabled', :enable_admin_mode do + it 'returns the list of instance clusters' do + is_expected.to eq([instance_cluster]) + end + end + + context 'when admin mode is disabled' do + it 'returns nothing' do + is_expected.to be_empty + end end end end diff --git a/spec/finders/group_projects_finder_spec.rb b/spec/finders/group_projects_finder_spec.rb index c66fdb19260..3fc4393df5d 100644 --- a/spec/finders/group_projects_finder_spec.rb +++ b/spec/finders/group_projects_finder_spec.rb @@ -142,20 +142,40 @@ RSpec.describe GroupProjectsFinder do describe 'with an admin current user' do let(:current_user) { create(:admin) } - context "only shared" do - let(:options) { { only_shared: true } } + context 'when admin mode is enabled', :enable_admin_mode do + context "only shared" do + let(:options) { { only_shared: true } } - it { is_expected.to eq([shared_project_3, shared_project_2, shared_project_1]) } - end + it { is_expected.to contain_exactly(shared_project_3, shared_project_2, shared_project_1) } + end - context "only owned" do - let(:options) { { only_owned: true } } + context "only owned" do + let(:options) { { only_owned: true } } + + it { is_expected.to contain_exactly(private_project, public_project) } + end - it { is_expected.to eq([private_project, public_project]) } + context "all" do + it { is_expected.to contain_exactly(shared_project_3, shared_project_2, shared_project_1, private_project, public_project) } + end end - context "all" do - it { is_expected.to eq([shared_project_3, shared_project_2, shared_project_1, private_project, public_project]) } + context 'when admin mode is disabled' do + context "only shared" do + let(:options) { { only_shared: true } } + + it { is_expected.to contain_exactly(shared_project_3, shared_project_1) } + end + + context "only owned" do + let(:options) { { only_owned: true } } + + it { is_expected.to contain_exactly(public_project) } + end + + context "all" do + it { is_expected.to contain_exactly(shared_project_3, shared_project_1, public_project) } + end end end diff --git a/spec/finders/groups_finder_spec.rb b/spec/finders/groups_finder_spec.rb index c9e9328794e..d69720ae98e 100644 --- a/spec/finders/groups_finder_spec.rb +++ b/spec/finders/groups_finder_spec.rb @@ -3,6 +3,8 @@ require 'spec_helper' RSpec.describe GroupsFinder do + include AdminModeHelper + describe '#execute' do let(:user) { create(:user) } @@ -23,11 +25,16 @@ RSpec.describe GroupsFinder do :external | { all_available: false } | %i(user_public_group user_internal_group user_private_group) :external | {} | %i(public_group user_public_group user_internal_group user_private_group) - :admin | { all_available: true } | %i(public_group internal_group private_group user_public_group - user_internal_group user_private_group) - :admin | { all_available: false } | %i(user_public_group user_internal_group user_private_group) - :admin | {} | %i(public_group internal_group private_group user_public_group user_internal_group - user_private_group) + :admin_without_admin_mode | { all_available: true } | %i(public_group internal_group user_public_group + user_internal_group user_private_group) + :admin_without_admin_mode | { all_available: false } | %i(user_public_group user_internal_group user_private_group) + :admin_without_admin_mode | {} | %i(public_group internal_group user_public_group user_internal_group user_private_group) + + :admin_with_admin_mode | { all_available: true } | %i(public_group internal_group private_group user_public_group + user_internal_group user_private_group) + :admin_with_admin_mode | { all_available: false } | %i(user_public_group user_internal_group user_private_group) + :admin_with_admin_mode | {} | %i(public_group internal_group private_group user_public_group user_internal_group + user_private_group) end with_them do @@ -52,8 +59,12 @@ RSpec.describe GroupsFinder do create(:user) when :external create(:user, external: true) - when :admin + when :admin_without_admin_mode create(:user, :admin) + when :admin_with_admin_mode + admin = create(:user, :admin) + enable_admin_mode!(admin) + admin end @groups.values_at(:user_private_group, :user_internal_group, :user_public_group).each do |group| group.add_developer(user) diff --git a/spec/finders/issues_finder_spec.rb b/spec/finders/issues_finder_spec.rb index d990cae61ca..33b8a5954ae 100644 --- a/spec/finders/issues_finder_spec.rb +++ b/spec/finders/issues_finder_spec.rb @@ -918,16 +918,26 @@ RSpec.describe IssuesFinder do describe '#row_count', :request_store do let_it_be(:admin) { create(:admin) } - it 'returns the number of rows for the default state' do - finder = described_class.new(admin) + context 'when admin mode is enabled', :enable_admin_mode do + it 'returns the number of rows for the default state' do + finder = described_class.new(admin) + + expect(finder.row_count).to eq(5) + end + + it 'returns the number of rows for a given state' do + finder = described_class.new(admin, state: 'closed') - expect(finder.row_count).to eq(5) + expect(finder.row_count).to be_zero + end end - it 'returns the number of rows for a given state' do - finder = described_class.new(admin, state: 'closed') + context 'when admin mode is disabled' do + it 'returns no rows' do + finder = described_class.new(admin) - expect(finder.row_count).to be_zero + expect(finder.row_count).to be_zero + end end it 'returns -1 if the query times out' do @@ -996,8 +1006,17 @@ RSpec.describe IssuesFinder do subject { described_class.new(admin_user, params).with_confidentiality_access_check } - it 'returns all issues' do - expect(subject).to include(public_issue, confidential_issue) + context 'when admin mode is enabled', :enable_admin_mode do + it 'returns all issues' do + expect(subject).to include(public_issue, confidential_issue) + end + end + + context 'when admin mode is disabled' do + it 'returns only public issues' do + expect(subject).to include(public_issue) + expect(subject).not_to include(confidential_issue) + end end end end @@ -1069,14 +1088,27 @@ RSpec.describe IssuesFinder do subject { described_class.new(admin_user, params).with_confidentiality_access_check } - it 'returns all issues' do - expect(subject).to include(public_issue, confidential_issue) + context 'when admin mode is enabled', :enable_admin_mode do + it 'returns all issues' do + expect(subject).to include(public_issue, confidential_issue) + end + + it 'does not filter by confidentiality' do + expect(Issue).not_to receive(:where).with(a_string_matching('confidential'), anything) + + subject + end end - it 'does not filter by confidentiality' do - expect(Issue).not_to receive(:where).with(a_string_matching('confidential'), anything) + context 'when admin mode is disabled' do + it 'returns only public issues' do + expect(subject).to include(public_issue) + expect(subject).not_to include(confidential_issue) + end - subject + it 'filters by confidentiality' do + expect(subject.to_sql).to match("issues.confidential") + end end end end diff --git a/spec/finders/merge_requests_finder_spec.rb b/spec/finders/merge_requests_finder_spec.rb index 7b59b581b1c..22d4362aaa9 100644 --- a/spec/finders/merge_requests_finder_spec.rb +++ b/spec/finders/merge_requests_finder_spec.rb @@ -697,10 +697,18 @@ RSpec.describe MergeRequestsFinder do context 'with admin user' do let(:user) { create(:user, :admin) } - it 'returns all merge requests' do - expect(merge_requests).to eq( - [mr_internal_private_repo_access, mr_private_repo_access, mr_internal, mr_private, mr_public] - ) + context 'when admin mode is enabled', :enable_admin_mode do + it 'returns all merge requests' do + expect(merge_requests).to contain_exactly( + mr_internal_private_repo_access, mr_private_repo_access, mr_internal, mr_private, mr_public + ) + end + end + + context 'when admin mode is disabled' do + it 'returns public and internal merge requests' do + expect(merge_requests).to contain_exactly(mr_internal, mr_public) + end end end diff --git a/spec/finders/projects_finder_spec.rb b/spec/finders/projects_finder_spec.rb index 4fa33978172..4d9ff30daba 100644 --- a/spec/finders/projects_finder_spec.rb +++ b/spec/finders/projects_finder_spec.rb @@ -2,7 +2,7 @@ require 'spec_helper' -RSpec.describe ProjectsFinder, :do_not_mock_admin_mode do +RSpec.describe ProjectsFinder do include AdminModeHelper describe '#execute' do diff --git a/spec/finders/snippets_finder_spec.rb b/spec/finders/snippets_finder_spec.rb index 6fc1cbcee0a..9c9a04a4df5 100644 --- a/spec/finders/snippets_finder_spec.rb +++ b/spec/finders/snippets_finder_spec.rb @@ -106,12 +106,18 @@ RSpec.describe SnippetsFinder do expect(snippets).to contain_exactly(public_personal_snippet) end - it 'returns all snippets for an admin' do + it 'returns all snippets for an admin in admin mode', :enable_admin_mode do snippets = described_class.new(admin, author: user).execute expect(snippets).to contain_exactly(private_personal_snippet, internal_personal_snippet, public_personal_snippet) end + it 'returns all public and internal snippets for an admin without admin mode' do + snippets = described_class.new(admin, author: user).execute + + expect(snippets).to contain_exactly(internal_personal_snippet, public_personal_snippet) + end + context 'when author is not valid' do it 'returns quickly' do finder = described_class.new(admin, author: non_existing_record_id) @@ -180,12 +186,18 @@ RSpec.describe SnippetsFinder do expect(snippets).to contain_exactly(private_project_snippet) end - it 'returns all snippets for an admin' do + it 'returns all snippets for an admin in admin mode', :enable_admin_mode do snippets = described_class.new(admin, project: project).execute expect(snippets).to contain_exactly(private_project_snippet, internal_project_snippet, public_project_snippet) end + it 'returns public and internal snippets for an admin without admin mode' do + snippets = described_class.new(admin, project: project).execute + + expect(snippets).to contain_exactly(internal_project_snippet, public_project_snippet) + end + context 'filter by author' do let!(:other_user) { create(:user) } let!(:other_private_project_snippet) { create(:project_snippet, :private, project: project, author: other_user) } @@ -218,7 +230,7 @@ RSpec.describe SnippetsFinder do end context 'filter by snippet type' do - context 'when filtering by only_personal snippet' do + context 'when filtering by only_personal snippet', :enable_admin_mode do it 'returns only personal snippet' do snippets = described_class.new(admin, only_personal: true).execute @@ -228,7 +240,7 @@ RSpec.describe SnippetsFinder do end end - context 'when filtering by only_project snippet' do + context 'when filtering by only_project snippet', :enable_admin_mode do it 'returns only project snippet' do snippets = described_class.new(admin, only_project: true).execute @@ -239,7 +251,7 @@ RSpec.describe SnippetsFinder do end end - context 'filtering by ids' do + context 'filtering by ids', :enable_admin_mode do it 'returns only personal snippet' do snippets = described_class.new( admin, ids: [private_personal_snippet.id, @@ -265,13 +277,21 @@ RSpec.describe SnippetsFinder do ) end - it 'returns all personal snippets for admins' do + it 'returns all personal snippets for admins when in admin mode', :enable_admin_mode do snippets = described_class.new(admin, explore: true).execute expect(snippets).to contain_exactly( private_personal_snippet, internal_personal_snippet, public_personal_snippet ) end + + it 'also returns internal personal snippets for admins without admin mode' do + snippets = described_class.new(admin, explore: true).execute + + expect(snippets).to contain_exactly( + internal_personal_snippet, public_personal_snippet + ) + end end context 'when the user cannot read cross project' do @@ -302,7 +322,7 @@ RSpec.describe SnippetsFinder do end end - context 'no sort param is provided' do + context 'no sort param is provided', :enable_admin_mode do it 'returns snippets sorted by id' do snippets = described_class.new(admin).execute @@ -310,7 +330,7 @@ RSpec.describe SnippetsFinder do end end - context 'sort param is provided' do + context 'sort param is provided', :enable_admin_mode do it 'returns snippets sorted by sort param' do snippets = described_class.new(admin, sort: 'updated_desc').execute diff --git a/spec/finders/users_finder_spec.rb b/spec/finders/users_finder_spec.rb index a04f5452fcd..24572a1c928 100644 --- a/spec/finders/users_finder_spec.rb +++ b/spec/finders/users_finder_spec.rb @@ -90,7 +90,7 @@ RSpec.describe UsersFinder do end end - context 'with an admin user' do + context 'with an admin user', :enable_admin_mode do let(:admin) { create(:admin) } it 'filters by external users' do diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index ef900446ad5..76d67195499 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -167,6 +167,50 @@ describe('Api', () => { }); }); + describe('addGroupMembersByUserId', () => { + it('adds an existing User as a new Group Member by User ID', () => { + const groupId = 1; + const expectedUserId = 2; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/members`; + const params = { + user_id: expectedUserId, + access_level: 10, + expires_at: undefined, + }; + + mock.onPost(expectedUrl).reply(200, { + id: expectedUserId, + state: 'active', + }); + + return Api.addGroupMembersByUserId(groupId, params).then(({ data }) => { + expect(data.id).toBe(expectedUserId); + expect(data.state).toBe('active'); + }); + }); + }); + + describe('inviteGroupMembersByEmail', () => { + it('invites a new email address to create a new User and become a Group Member', () => { + const groupId = 1; + const email = 'email@example.com'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/1/invitations`; + const params = { + email, + access_level: 10, + expires_at: undefined, + }; + + mock.onPost(expectedUrl).reply(200, { + status: 'success', + }); + + return Api.inviteGroupMembersByEmail(groupId, params).then(({ data }) => { + expect(data.status).toBe('success'); + }); + }); + }); + describe('groupMilestones', () => { it('fetches group milestones', (done) => { const groupId = '16'; @@ -458,6 +502,50 @@ describe('Api', () => { }); }); + describe('addProjectMembersByUserId', () => { + it('adds an existing User as a new Project Member by User ID', () => { + const projectId = 1; + const expectedUserId = 2; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/members`; + const params = { + user_id: expectedUserId, + access_level: 10, + expires_at: undefined, + }; + + mock.onPost(expectedUrl).reply(200, { + id: expectedUserId, + state: 'active', + }); + + return Api.addProjectMembersByUserId(projectId, params).then(({ data }) => { + expect(data.id).toBe(expectedUserId); + expect(data.state).toBe('active'); + }); + }); + }); + + describe('inviteProjectMembersByEmail', () => { + it('invites a new email address to create a new User and become a Project Member', () => { + const projectId = 1; + const expectedEmail = 'email@example.com'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/invitations`; + const params = { + email: expectedEmail, + access_level: 10, + expires_at: undefined, + }; + + mock.onPost(expectedUrl).reply(200, { + status: 'success', + }); + + return Api.inviteProjectMembersByEmail(projectId, params).then(({ data }) => { + expect(data.status).toBe('success'); + }); + }); + }); + describe('newLabel', () => { it('creates a new label', (done) => { const namespace = 'some namespace'; diff --git a/spec/frontend/boards/boards_store_spec.js b/spec/frontend/boards/boards_store_spec.js index 09a5535537e..0827c588056 100644 --- a/spec/frontend/boards/boards_store_spec.js +++ b/spec/frontend/boards/boards_store_spec.js @@ -456,24 +456,6 @@ describe('boardsStore', () => { }); }); - describe('deleteBoard', () => { - const id = 'capsized'; - const url = `${endpoints.boardsEndpoint}/${id}.json`; - - it('makes a request to delete a boards', () => { - axiosMock.onDelete(url).replyOnce(200, dummyResponse); - const expectedResponse = expect.objectContaining({ data: dummyResponse }); - - return expect(boardsStore.deleteBoard({ id })).resolves.toEqual(expectedResponse); - }); - - it('fails for error response', () => { - axiosMock.onDelete(url).replyOnce(500); - - return expect(boardsStore.deleteBoard({ id })).rejects.toThrow(); - }); - }); - describe('when created', () => { beforeEach(() => { setupDefaultResponses(); diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index da79becc82a..e671a2dfb57 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -4,16 +4,19 @@ import { TEST_HOST } from 'jest/helpers/test_constants'; import { GlModal } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; import boardsStore from '~/boards/stores/boards_store'; import BoardForm from '~/boards/components/board_form.vue'; import updateBoardMutation from '~/boards/graphql/board_update.mutation.graphql'; import createBoardMutation from '~/boards/graphql/board_create.mutation.graphql'; +import destroyBoardMutation from '~/boards/graphql/board_destroy.mutation.graphql'; jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn().mockName('visitUrlMock'), stripFinalUrlSegment: jest.requireActual('~/lib/utils/url_utility').stripFinalUrlSegment, })); +jest.mock('~/flash'); const currentBoard = { id: 1, @@ -34,19 +37,9 @@ const defaultProps = { currentBoard, }; -const endpoints = { - boardsEndpoint: 'test-endpoint', -}; - -const mutate = jest.fn().mockResolvedValue({ - data: { - createBoard: { board: { id: 'gid://gitlab/Board/123' } }, - updateBoard: { board: { id: 'gid://gitlab/Board/321' } }, - }, -}); - describe('BoardForm', () => { let wrapper; + let mutate; const findModal = () => wrapper.find(GlModal); const findModalActionPrimary = () => findModal().props('actionPrimary'); @@ -64,7 +57,7 @@ describe('BoardForm', () => { }; }, provide: { - endpoints, + rootPath: 'root', }, mocks: { $apollo: { @@ -83,6 +76,7 @@ describe('BoardForm', () => { wrapper.destroy(); wrapper = null; boardsStore.state.currentPage = null; + mutate = null; }); describe('when user can not admin the board', () => { @@ -156,6 +150,20 @@ describe('BoardForm', () => { }); describe('when submitting a create event', () => { + const fillForm = () => { + findInput().value = 'Test name'; + findInput().trigger('input'); + findInput().trigger('keyup.enter', { metaKey: true }); + }; + + beforeEach(() => { + mutate = jest.fn().mockResolvedValue({ + data: { + createBoard: { board: { id: 'gid://gitlab/Board/123' } }, + }, + }); + }); + it('does not call API if board name is empty', async () => { createComponent({ canAdminBoard: true }); findInput().trigger('keyup.enter', { metaKey: true }); @@ -168,10 +176,7 @@ describe('BoardForm', () => { it('calls a correct GraphQL mutation and redirects to correct page from existing board', async () => { window.location = new URL('https://test/boards/1'); createComponent({ canAdminBoard: true }); - - findInput().value = 'Test name'; - findInput().trigger('input'); - findInput().trigger('keyup.enter', { metaKey: true }); + fillForm(); await waitForPromises(); @@ -191,10 +196,7 @@ describe('BoardForm', () => { it('calls a correct GraphQL mutation and redirects to correct page from boards list', async () => { window.location = new URL('https://test/boards'); createComponent({ canAdminBoard: true }); - - findInput().value = 'Test name'; - findInput().trigger('input'); - findInput().trigger('keyup.enter', { metaKey: true }); + fillForm(); await waitForPromises(); @@ -210,6 +212,20 @@ describe('BoardForm', () => { await waitForPromises(); expect(visitUrl).toHaveBeenCalledWith('boards/123'); }); + + it('shows an error flash if GraphQL mutation fails', async () => { + mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); + createComponent({ canAdminBoard: true }); + fillForm(); + + await waitForPromises(); + + expect(mutate).toHaveBeenCalled(); + + await waitForPromises(); + expect(visitUrl).not.toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalled(); + }); }); }); @@ -245,27 +261,93 @@ describe('BoardForm', () => { }); }); - describe('when submitting an update event', () => { - it('calls REST and GraphQL API with correct parameters', async () => { - window.location = new URL('https://test/boards/1'); - createComponent({ canAdminBoard: true }); + it('calls GraphQL mutation with correct parameters', async () => { + mutate = jest.fn().mockResolvedValue({ + data: { + updateBoard: { board: { id: 'gid://gitlab/Board/321' } }, + }, + }); + window.location = new URL('https://test/boards/1'); + createComponent({ canAdminBoard: true }); - findInput().trigger('keyup.enter', { metaKey: true }); + findInput().trigger('keyup.enter', { metaKey: true }); - await waitForPromises(); + await waitForPromises(); - expect(mutate).toHaveBeenCalledWith({ - mutation: updateBoardMutation, - variables: { - input: expect.objectContaining({ - id: `gid://gitlab/Board/${currentBoard.id}`, - }), - }, - }); + expect(mutate).toHaveBeenCalledWith({ + mutation: updateBoardMutation, + variables: { + input: expect.objectContaining({ + id: `gid://gitlab/Board/${currentBoard.id}`, + }), + }, + }); - await waitForPromises(); - expect(visitUrl).toHaveBeenCalledWith('321'); + await waitForPromises(); + expect(visitUrl).toHaveBeenCalledWith('321'); + }); + + it('shows an error flash if GraphQL mutation fails', async () => { + mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); + createComponent({ canAdminBoard: true }); + findInput().trigger('keyup.enter', { metaKey: true }); + + await waitForPromises(); + + expect(mutate).toHaveBeenCalled(); + + await waitForPromises(); + expect(visitUrl).not.toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalled(); + }); + }); + + describe('when deleting a board', () => { + beforeEach(() => { + boardsStore.state.currentPage = 'delete'; + }); + + it('passes correct primary action text and variant', () => { + createComponent({ canAdminBoard: true }); + expect(findModalActionPrimary().text).toBe('Delete'); + expect(findModalActionPrimary().attributes[0].variant).toBe('danger'); + }); + + it('renders delete confirmation message', () => { + createComponent({ canAdminBoard: true }); + expect(findDeleteConfirmation().exists()).toBe(true); + }); + + it('calls a correct GraphQL mutation and redirects to correct page after deleting board', async () => { + mutate = jest.fn().mockResolvedValue({}); + createComponent({ canAdminBoard: true }); + findModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(mutate).toHaveBeenCalledWith({ + mutation: destroyBoardMutation, + variables: { + id: 'gid://gitlab/Board/1', + }, }); + + await waitForPromises(); + expect(visitUrl).toHaveBeenCalledWith('root'); + }); + + it('shows an error flash if GraphQL mutation fails', async () => { + mutate = jest.fn().mockRejectedValue('Houston, we have a problem'); + createComponent({ canAdminBoard: true }); + findModal().vm.$emit('primary'); + + await waitForPromises(); + + expect(mutate).toHaveBeenCalled(); + + await waitForPromises(); + expect(visitUrl).not.toHaveBeenCalled(); + expect(createFlash).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/fixtures/project_analytics.rb b/spec/frontend/fixtures/project_analytics.rb new file mode 100644 index 00000000000..f0be5e8b97d --- /dev/null +++ b/spec/frontend/fixtures/project_analytics.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Project Analytics (JavaScript fixtures)' do + include ApiHelpers + include JavaScriptFixturesHelpers + + let_it_be(:reporter) { create(:user) } + let_it_be(:project) { create(:project, :repository) } + let_it_be(:environment) { create(:environment, project: project, name: 'production') } + + let!(:deployments) do + [ + 1.minute.ago, + 2.days.ago, + 3.days.ago, + 3.days.ago, + 3.days.ago, + 8.days.ago, + 32.days.ago, + 91.days.ago + ].map do |finished_at| + create(:deployment, + :success, + project: project, + environment: environment, + finished_at: finished_at) + end + end + + before do + stub_licensed_features(project_activity_analytics: true) + project.add_reporter(reporter) + sign_in(reporter) + end + + after(:all) do + remove_repository(project) + end + + describe API::Analytics::ProjectDeploymentFrequency, type: :request do + before(:all) do + clean_frontend_fixtures('api/project_analytics/') + end + + let(:shared_params) { { environment: environment.name, interval: 'daily' } } + + def make_request(additional_query_params:) + params = shared_params.merge(additional_query_params) + get api("/projects/#{project.id}/analytics/deployment_frequency?#{params.to_query}", reporter) + end + + it 'api/project_analytics/daily_deployment_frequencies_for_last_week.json' do + make_request(additional_query_params: { from: 1.week.ago }) + expect(response).to be_successful + end + + it 'api/project_analytics/daily_deployment_frequencies_for_last_month.json' do + make_request(additional_query_params: { from: 1.month.ago }) + expect(response).to be_successful + end + + it 'api/project_analytics/daily_deployment_frequencies_for_last_90_days.json' do + make_request(additional_query_params: { from: 90.days.ago }) + expect(response).to be_successful + end + end +end diff --git a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js index 2c601bcf99d..27f642d15c8 100644 --- a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js +++ b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js @@ -142,6 +142,29 @@ describe('ImportProjectsTable', () => { }, ); + it.each` + importingRepoCount | buttonMessage + ${1} | ${'Importing 1 repository'} + ${5} | ${'Importing 5 repositories'} + `( + 'sets the button text to "$buttonMessage" when importing repos', + ({ importingRepoCount, buttonMessage }) => { + createComponent({ + state: { + providerRepos: [providerRepo], + }, + getters: { + hasIncompatibleRepos: () => false, + importAllCount: () => 10, + isImportingAnyRepo: () => true, + importingRepoCount: () => importingRepoCount, + }, + }); + + expect(findImportAllButton().text()).toBe(buttonMessage); + }, + ); + it('renders an empty state if there are no repositories available', () => { createComponent({ state: { repositories: [] } }); @@ -168,7 +191,7 @@ describe('ImportProjectsTable', () => { }); it('shows loading spinner when import is in progress', () => { - createComponent({ getters: { isImportingAnyRepo: () => true } }); + createComponent({ getters: { isImportingAnyRepo: () => true, importallCount: () => 1 } }); expect(findImportAllButton().props().loading).toBe(true); }); diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js index d9bedb964a3..4eb1db70b11 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import { GlDropdown, GlDropdownItem, GlDatepicker, GlSprintf, GlLink, GlModal } from '@gitlab/ui'; import { stubComponent } from 'helpers/stub_component'; +import waitForPromises from 'helpers/wait_for_promises'; import Api from '~/api'; import InviteMembersModal from '~/invite_members/components/invite_members_modal.vue'; @@ -11,6 +12,15 @@ const accessLevels = { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, O const defaultAccessLevel = '10'; const helpLink = 'https://example.com'; +const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' }; +const user2 = { id: 2, name: 'Name Two', username: 'one_2', avatar_url: '' }; +const user3 = { + id: 'user-defined-token', + name: 'email@example.com', + username: 'one_2', + avatar_url: '', +}; + const createComponent = (data = {}) => { return shallowMount(InviteMembersModal, { propsData: { @@ -50,6 +60,7 @@ describe('InviteMembersModal', () => { const findLink = () => wrapper.find(GlLink); const findCancelButton = () => wrapper.find({ ref: 'cancelButton' }); const findInviteButton = () => wrapper.find({ ref: 'inviteButton' }); + const clickInviteButton = () => findInviteButton().vm.$emit('click'); describe('rendering the modal', () => { beforeEach(() => { @@ -92,78 +103,184 @@ describe('InviteMembersModal', () => { }); describe('submitting the invite form', () => { - const postData = { - user_id: '1', - access_level: '10', - expires_at: new Date(), - format: 'json', - }; + const apiErrorMessage = 'Member already exists'; + + describe('when inviting an existing user to group by user ID', () => { + const postData = { + user_id: '1', + access_level: '10', + expires_at: undefined, + format: 'json', + }; + + describe('when invites are sent successfully', () => { + beforeEach(() => { + wrapper = createComponent({ newUsersToInvite: [user1] }); - describe('when the invite was sent successfully', () => { - beforeEach(() => { - wrapper = createComponent(); + wrapper.vm.$toast = { show: jest.fn() }; + jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); + jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); - wrapper.vm.$toast = { show: jest.fn() }; - jest.spyOn(Api, 'inviteGroupMember').mockResolvedValue({ data: postData }); + clickInviteButton(); + }); - wrapper.vm.submitForm(postData); + it('calls Api addGroupMembersByUserId with the correct params', () => { + expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, postData); + }); + + it('displays the successful toastMessage', () => { + expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + }); }); - it('displays the successful toastMessage', () => { - const toastMessageSuccessful = 'Members were successfully added'; + describe('when the invite received an api error message', () => { + beforeEach(() => { + wrapper = createComponent({ newUsersToInvite: [user1] }); + + wrapper.vm.$toast = { show: jest.fn() }; + jest + .spyOn(Api, 'addGroupMembersByUserId') + .mockRejectedValue({ response: { data: { message: apiErrorMessage } } }); + jest.spyOn(wrapper.vm, 'showToastMessageError'); + + clickInviteButton(); + }); - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith( - toastMessageSuccessful, - wrapper.vm.toastOptions, - ); + it('displays the apiErrorMessage in the toastMessage', async () => { + await waitForPromises(); + + expect(wrapper.vm.showToastMessageError).toHaveBeenCalledWith({ + response: { data: { message: apiErrorMessage } }, + }); + }); }); - it('calls Api inviteGroupMember with the correct params', () => { - expect(Api.inviteGroupMember).toHaveBeenCalledWith(id, postData); + describe('when any invite failed for any other reason', () => { + beforeEach(() => { + wrapper = createComponent({ newUsersToInvite: [user1, user2] }); + + wrapper.vm.$toast = { show: jest.fn() }; + jest + .spyOn(Api, 'addGroupMembersByUserId') + .mockRejectedValue({ response: { data: { success: false } } }); + jest.spyOn(wrapper.vm, 'showToastMessageError'); + + clickInviteButton(); + }); + + it('displays the generic error toastMessage', async () => { + await waitForPromises(); + + expect(wrapper.vm.showToastMessageError).toHaveBeenCalled(); + }); }); }); - describe('when sending the invite for a single member returned an api error', () => { - const apiErrorMessage = 'Members already exists'; + describe('when inviting a new user by email address', () => { + const postData = { + access_level: '10', + expires_at: undefined, + email: 'email@example.com', + format: 'json', + }; + + describe('when invites are sent successfully', () => { + beforeEach(() => { + wrapper = createComponent({ newUsersToInvite: [user3] }); + + wrapper.vm.$toast = { show: jest.fn() }; + jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); + jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); - beforeEach(() => { - wrapper = createComponent({ newUsersToInvite: '123' }); + clickInviteButton(); + }); - wrapper.vm.$toast = { show: jest.fn() }; - jest - .spyOn(Api, 'inviteGroupMember') - .mockRejectedValue({ response: { data: { message: apiErrorMessage } } }); + it('calls Api inviteGroupMembersByEmail with the correct params', () => { + expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, postData); + }); - findInviteButton().vm.$emit('click'); + it('displays the successful toastMessage', () => { + expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + }); }); - it('displays the api error message for the toastMessage', () => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith( - apiErrorMessage, - wrapper.vm.toastOptions, - ); + describe('when any invite failed for any reason', () => { + beforeEach(() => { + wrapper = createComponent({ newUsersToInvite: [user1, user2] }); + + wrapper.vm.$toast = { show: jest.fn() }; + jest + .spyOn(Api, 'addGroupMembersByUserId') + .mockRejectedValue({ response: { data: { success: false } } }); + jest.spyOn(wrapper.vm, 'showToastMessageError'); + + clickInviteButton(); + }); + + it('displays the generic error toastMessage', async () => { + await waitForPromises(); + + expect(wrapper.vm.showToastMessageError).toHaveBeenCalled(); + }); }); }); - describe('when sending the invite for multiple members returned any error', () => { - const genericErrorMessage = 'Some of the members could not be added'; + describe('when inviting members and non-members in same click', () => { + const postData = { + access_level: '10', + expires_at: undefined, + format: 'json', + }; + + const emailPostData = { ...postData, email: 'email@example.com' }; + const idPostData = { ...postData, user_id: '1' }; + + describe('when invites are sent successfully', () => { + beforeEach(() => { + wrapper = createComponent({ newUsersToInvite: [user1, user3] }); + + wrapper.vm.$toast = { show: jest.fn() }; + jest.spyOn(Api, 'inviteGroupMembersByEmail').mockResolvedValue({ data: postData }); + jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); + jest.spyOn(wrapper.vm, 'showToastMessageSuccess'); - beforeEach(() => { - wrapper = createComponent({ newUsersToInvite: '123' }); + clickInviteButton(); + }); - wrapper.vm.$toast = { show: jest.fn() }; - jest - .spyOn(Api, 'inviteGroupMember') - .mockRejectedValue({ response: { data: { success: false } } }); + it('calls Api inviteGroupMembersByEmail with the correct params', () => { + expect(Api.inviteGroupMembersByEmail).toHaveBeenCalledWith(id, emailPostData); + }); - findInviteButton().vm.$emit('click'); + it('calls Api addGroupMembersByUserId with the correct params', () => { + expect(Api.addGroupMembersByUserId).toHaveBeenCalledWith(id, idPostData); + }); + + it('displays the successful toastMessage', () => { + expect(wrapper.vm.showToastMessageSuccess).toHaveBeenCalled(); + }); }); - it('displays the expected toastMessage', () => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith( - genericErrorMessage, - wrapper.vm.toastOptions, - ); + describe('when any invite failed for any reason', () => { + beforeEach(() => { + wrapper = createComponent({ newUsersToInvite: [user1, user3] }); + + wrapper.vm.$toast = { show: jest.fn() }; + + jest + .spyOn(Api, 'inviteGroupMembersByEmail') + .mockRejectedValue({ response: { data: { success: false } } }); + + jest.spyOn(Api, 'addGroupMembersByUserId').mockResolvedValue({ data: postData }); + jest.spyOn(wrapper.vm, 'showToastMessageError'); + + clickInviteButton(); + }); + + it('displays the generic error toastMessage', async () => { + await waitForPromises(); + + expect(wrapper.vm.showToastMessageError).toHaveBeenCalled(); + }); }); }); }); diff --git a/spec/frontend/invite_members/components/members_token_select_spec.js b/spec/frontend/invite_members/components/members_token_select_spec.js index 106a2df783d..d66b715689f 100644 --- a/spec/frontend/invite_members/components/members_token_select_spec.js +++ b/spec/frontend/invite_members/components/members_token_select_spec.js @@ -8,8 +8,8 @@ import MembersTokenSelect from '~/invite_members/components/members_token_select const label = 'testgroup'; const placeholder = 'Search for a member'; -const user1 = { id: 1, name: 'Name One', username: 'one_1', avatar_url: '' }; -const user2 = { id: 2, name: 'Name Two', username: 'two_2', avatar_url: '' }; +const user1 = { id: 1, name: 'John Smith', username: 'one_1', avatar_url: '' }; +const user2 = { id: 2, name: 'Jane Doe', username: 'two_2', avatar_url: '' }; const allUsers = [user1, user2]; const createComponent = () => { @@ -77,9 +77,14 @@ describe('MembersTokenSelect', () => { }); describe('when text input is typed in', () => { + let tokenSelector; + + beforeEach(() => { + tokenSelector = findTokenSelector(); + }); + it('calls the API with search parameter', async () => { const searchParam = 'One'; - const tokenSelector = findTokenSelector(); tokenSelector.vm.$emit('text-input', searchParam); @@ -88,16 +93,23 @@ describe('MembersTokenSelect', () => { expect(Api.users).toHaveBeenCalledWith(searchParam, wrapper.vm.$options.queryOptions); expect(tokenSelector.props('hideDropdownWithNoItems')).toBe(false); }); + + describe('when input text is an email', () => { + it('allows user defined tokens', async () => { + tokenSelector.vm.$emit('text-input', 'foo@bar.com'); + + await nextTick(); + + expect(tokenSelector.props('allowUserDefinedTokens')).toBe(true); + }); + }); }); describe('when user is selected', () => { it('emits `input` event with selected users', () => { - findTokenSelector().vm.$emit('input', [ - { id: 1, name: 'John Smith' }, - { id: 2, name: 'Jane Doe' }, - ]); + findTokenSelector().vm.$emit('input', [user1, user2]); - expect(wrapper.emitted().input[0][0]).toBe('1,2'); + expect(wrapper.emitted().input[0][0]).toEqual([user1, user2]); }); }); }); diff --git a/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_deployed_to_production_spec.rb b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_deployed_to_production_spec.rb new file mode 100644 index 00000000000..93e588675d3 --- /dev/null +++ b/spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_deployed_to_production_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Gitlab::Analytics::CycleAnalytics::StageEvents::IssueDeployedToProduction do + it_behaves_like 'value stream analytics event' +end diff --git a/spec/lib/gitlab/data_builder/build_spec.rb b/spec/lib/gitlab/data_builder/build_spec.rb index cfaaf849b09..2f74e766a11 100644 --- a/spec/lib/gitlab/data_builder/build_spec.rb +++ b/spec/lib/gitlab/data_builder/build_spec.rb @@ -26,6 +26,7 @@ RSpec.describe Gitlab::DataBuilder::Build do it { expect(data[:user]).to eq( { + id: user.id, name: user.name, username: user.username, avatar_url: user.avatar_url(only_path: false), diff --git a/spec/lib/gitlab/data_builder/pipeline_spec.rb b/spec/lib/gitlab/data_builder/pipeline_spec.rb index e5dfff33a2a..297d87708d8 100644 --- a/spec/lib/gitlab/data_builder/pipeline_spec.rb +++ b/spec/lib/gitlab/data_builder/pipeline_spec.rb @@ -41,6 +41,7 @@ RSpec.describe Gitlab::DataBuilder::Pipeline do expect(project_data).to eq(project.hook_attrs(backward: false)) expect(data[:merge_request]).to be_nil expect(data[:user]).to eq({ + id: user.id, name: user.name, username: user.username, avatar_url: user.avatar_url(only_path: false), diff --git a/spec/models/ci/build_spec.rb b/spec/models/ci/build_spec.rb index 8e34a303956..bb3228c2bf0 100644 --- a/spec/models/ci/build_spec.rb +++ b/spec/models/ci/build_spec.rb @@ -580,9 +580,27 @@ RSpec.describe Ci::Build do is_expected.to be_falsey end - it 'that cannot handle build' do - expect_any_instance_of(Ci::Runner).to receive(:can_pick?).and_return(false) - is_expected.to be_falsey + context 'when runners are on-line but none can pick a build' do + before do + allow_any_instance_of(Ci::Runner) + .to receive(:can_pick?).and_return(false) + end + + context 'when a performance experiement feature flag is enabled' do + before do + stub_feature_flags(ci_build_stuck_badge_performance_experiment: true) + end + + it { is_expected.to be_truthy } + end + + context 'when a performance experiment is not running' do + before do + stub_feature_flags(ci_build_stuck_badge_performance_experiment: false) + end + + it { is_expected.to be_falsey } + end end end end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 3c5bc125011..c5e942e673b 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -5089,9 +5089,10 @@ RSpec.describe User do end describe '#hook_attrs' do - it 'includes name, username, avatar_url, and email' do + it 'includes id, name, username, avatar_url, and email' do user = create(:user) user_attributes = { + id: user.id, name: user.name, username: user.username, avatar_url: user.avatar_url(only_path: false), diff --git a/spec/rubocop/cop/rspec/web_mock_enable_spec.rb b/spec/rubocop/cop/rspec/web_mock_enable_spec.rb new file mode 100644 index 00000000000..61a85064a61 --- /dev/null +++ b/spec/rubocop/cop/rspec/web_mock_enable_spec.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +require 'fast_spec_helper' + +require_relative '../../../../rubocop/cop/rspec/web_mock_enable' + +RSpec.describe RuboCop::Cop::RSpec::WebMockEnable do + subject(:cop) { described_class.new } + + context 'when calling WebMock.disable_net_connect!' do + it 'registers an offence and autocorrects it' do + expect_offense(<<~RUBY) + WebMock.disable_net_connect!(allow_localhost: true) + ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ Use webmock_enable! instead of calling WebMock.disable_net_connect! directly. + RUBY + + expect_correction(<<~RUBY) + webmock_enable! + RUBY + end + end +end diff --git a/spec/serializers/build_details_entity_spec.rb b/spec/serializers/build_details_entity_spec.rb index 1adcf236b98..4a58f341658 100644 --- a/spec/serializers/build_details_entity_spec.rb +++ b/spec/serializers/build_details_entity_spec.rb @@ -5,14 +5,13 @@ require 'spec_helper' RSpec.describe BuildDetailsEntity do include ProjectForksHelper - let_it_be(:user) { create(:admin) } - it 'inherits from JobEntity' do expect(described_class).to be < JobEntity end describe '#as_json' do let(:project) { create(:project, :repository) } + let(:user) { project.owner } let(:pipeline) { create(:ci_pipeline, project: project) } let(:build) { create(:ci_build, :failed, pipeline: pipeline) } let(:request) { double('request', project: project) } @@ -66,6 +65,7 @@ RSpec.describe BuildDetailsEntity do before do allow(build).to receive(:merge_request).and_return(merge_request) + forked_project.add_developer(user) end let(:merge_request) do @@ -186,7 +186,7 @@ RSpec.describe BuildDetailsEntity do end context 'when the build has expired artifacts' do - let!(:build) { create(:ci_build, :artifacts, artifacts_expire_at: 7.days.ago) } + let!(:build) { create(:ci_build, :artifacts, pipeline: pipeline, artifacts_expire_at: 7.days.ago) } context 'when pipeline is unlocked' do before do @@ -218,7 +218,7 @@ RSpec.describe BuildDetailsEntity do end context 'when the build has archive type artifacts' do - let!(:build) { create(:ci_build, :artifacts, artifacts_expire_at: 7.days.from_now) } + let!(:build) { create(:ci_build, :artifacts, pipeline: pipeline, artifacts_expire_at: 7.days.from_now) } let!(:report) { create(:ci_job_artifact, :codequality, job: build) } it 'exposes artifact details' do diff --git a/spec/serializers/deploy_key_entity_spec.rb b/spec/serializers/deploy_key_entity_spec.rb index 3404d27a23c..e8d9701be67 100644 --- a/spec/serializers/deploy_key_entity_spec.rb +++ b/spec/serializers/deploy_key_entity_spec.rb @@ -52,7 +52,13 @@ RSpec.describe DeployKeyEntity do context 'user is an admin' do let(:user) { create(:user, :admin) } - it { expect(entity.as_json).to include(can_edit: true) } + context 'when admin mode is enabled', :enable_admin_mode do + it { expect(entity.as_json).to include(can_edit: true) } + end + + context 'when admin mode is disabled' do + it { expect(entity.as_json).not_to include(can_edit: true) } + end end context 'user is a project maintainer' do diff --git a/spec/serializers/runner_entity_spec.rb b/spec/serializers/runner_entity_spec.rb index 84c7d1720e2..e864b52c0f2 100644 --- a/spec/serializers/runner_entity_spec.rb +++ b/spec/serializers/runner_entity_spec.rb @@ -7,7 +7,7 @@ RSpec.describe RunnerEntity do let(:runner) { create(:ci_runner, :project, projects: [project]) } let(:entity) { described_class.new(runner, request: request, current_user: user) } let(:request) { double('request') } - let(:user) { create(:admin) } + let(:user) { project.owner } before do allow(request).to receive(:current_user).and_return(user) diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index e88cad93e9c..c5560ea462f 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -272,38 +272,6 @@ RSpec.configure do |config| Sidekiq::Worker.clear_all - # Temporary patch to force admin mode to be active by default in tests when - # using the feature flag :user_mode_in_session, since this will require - # modifying a significant number of specs to test both states for admin - # mode enabled / disabled. - # - # This will only be applied to specs below dirs in `admin_mode_mock_dirs` - # - # See ongoing migration: https://gitlab.com/gitlab-org/gitlab/-/issues/31511 - # - # Until the migration is finished, if it is required to have the real - # behaviour in any of the mocked dirs specs that an admin is signed in - # with normal user mode and needs to switch to admin mode, it is possible to - # mark such tests with the `do_not_mock_admin_mode` metadata tag, e.g: - # - # context 'some test in mocked dir', :do_not_mock_admin_mode do ... end - admin_mode_mock_dirs = %w( - ./ee/spec/elastic_integration - ./ee/spec/finders - ./ee/spec/serializers - ./ee/spec/support/shared_examples/finders/geo - ./ee/spec/support/shared_examples/graphql/geo - ./spec/finders - ./spec/serializers - ./spec/workers - ) - - if !example.metadata[:do_not_mock_admin_mode] && example.metadata[:file_path].start_with?(*admin_mode_mock_dirs) - allow_any_instance_of(Gitlab::Auth::CurrentUserMode).to receive(:admin_mode?) do |current_user_mode| - current_user_mode.send(:user)&.admin? - end - end - # Administrators have to re-authenticate in order to access administrative # functionality when feature flag :user_mode_in_session is active. Any spec # that requires administrative access can use the tag :enable_admin_mode @@ -311,6 +279,10 @@ RSpec.configure do |config| # # context 'some test that requires admin mode', :enable_admin_mode do ... end # + # Some specs do get admin mode enabled automatically (e.g. `spec/controllers/admin`). + # In this case, specs that need to test both admin mode states can use the + # :do_not_mock_admin_mode tag to disable auto admin mode. + # # See also spec/support/helpers/admin_mode_helpers.rb if example.metadata[:enable_admin_mode] && !example.metadata[:do_not_mock_admin_mode] allow_any_instance_of(Gitlab::Auth::CurrentUserMode).to receive(:admin_mode?) do |current_user_mode| diff --git a/spec/workers/group_destroy_worker_spec.rb b/spec/workers/group_destroy_worker_spec.rb index ab3dd19dec1..82ae9010a24 100644 --- a/spec/workers/group_destroy_worker_spec.rb +++ b/spec/workers/group_destroy_worker_spec.rb @@ -4,8 +4,12 @@ require 'spec_helper' RSpec.describe GroupDestroyWorker do let(:group) { create(:group) } - let(:user) { create(:admin) } let!(:project) { create(:project, namespace: group) } + let(:user) { create(:user) } + + before do + group.add_owner(user) + end subject { described_class.new } |