summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--.gitlab/issue_templates/Acceptance Testing.md2
-rw-r--r--.rubocop.yml8
-rw-r--r--GITALY_SERVER_VERSION2
-rw-r--r--Gemfile2
-rw-r--r--Gemfile.lock34
-rw-r--r--app/assets/javascripts/api.js18
-rw-r--r--app/assets/javascripts/boards/components/board_form.vue13
-rw-r--r--app/assets/javascripts/boards/graphql/board_destroy.mutation.graphql7
-rw-r--r--app/assets/javascripts/boards/index.js1
-rw-r--r--app/assets/javascripts/boards/mount_multiple_boards_switcher.js1
-rw-r--r--app/assets/javascripts/boards/stores/boards_store.js4
-rw-r--r--app/assets/javascripts/diffs/components/commit_item.vue6
-rw-r--r--app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue19
-rw-r--r--app/assets/javascripts/import_entities/import_projects/store/getters.js12
-rw-r--r--app/assets/javascripts/import_entities/import_projects/utils.js6
-rw-r--r--app/assets/javascripts/invite_members/components/invite_members_modal.vue55
-rw-r--r--app/assets/javascripts/invite_members/components/members_token_select.vue29
-rw-r--r--app/assets/javascripts/invite_members/init_invite_members_modal.js2
-rw-r--r--app/helpers/projects/alert_management_helper.rb2
-rw-r--r--app/models/ci/build.rb8
-rw-r--r--app/models/project_services/alerts_service.rb2
-rw-r--r--app/models/user.rb1
-rw-r--r--app/views/admin/users/_admin_notes.html.haml2
-rw-r--r--app/views/groups/_invite_members_modal.html.haml2
-rw-r--r--app/views/groups/group_members/index.html.haml84
-rw-r--r--app/views/projects/_invite_members_modal.html.haml2
-rw-r--r--app/views/projects/jobs/index.html.haml5
-rw-r--r--changelogs/unreleased/231073-apply-gitlab-ui-button-styles-to-buttons-in-ee-app-views-projects-.yml5
-rw-r--r--changelogs/unreleased/238854-fix-copy-sha-in-add-previous-commits-modal-doesnt-copy.yml5
-rw-r--r--changelogs/unreleased/244274-boards-migrate-deleteboard-board_store-function-to-vuex-action.yml5
-rw-r--r--changelogs/unreleased/250659-allow-users-to-use-production-stage-end-event.yml5
-rw-r--r--changelogs/unreleased/285634-Fix-confusion-button-text-when-importing-from-github.yml5
-rw-r--r--changelogs/unreleased/add-user-id-to-user-webhook-data.yml5
-rw-r--r--changelogs/unreleased/align-admin-notes-label-to-the-left.yml5
-rw-r--r--changelogs/unreleased/chore-disable-admin-mode-remains.yml5
-rw-r--r--changelogs/unreleased/fix-casing-of-ci-lint-on-jobs-page.yml5
-rw-r--r--changelogs/unreleased/fix-gb-ci-build-stuck-badge-performance-experiment.yml5
-rw-r--r--config/feature_flags/development/ci_build_stuck_badge_performance_experiment.yml8
-rw-r--r--config/feature_flags/development/coverage_fuzzing_mr_widget.yml8
-rw-r--r--config/known_invalid_graphql_queries.yml1
-rw-r--r--doc/api/projects.md62
-rw-r--r--doc/development/go_guide/index.md7
-rw-r--r--doc/system_hooks/system_hooks.md4
-rw-r--r--doc/user/group/value_stream_analytics/index.md9
-rw-r--r--doc/user/project/integrations/webhooks.md51
-rw-r--r--doc/user/project/service_desk.md6
-rw-r--r--lib/gitlab/analytics/cycle_analytics/stage_events.rb9
-rw-r--r--lib/gitlab/analytics/cycle_analytics/stage_events/issue_deployed_to_production.rb (renamed from lib/gitlab/analytics/cycle_analytics/stage_events/production_stage_end.rb)4
-rw-r--r--locale/gitlab.pot14
-rw-r--r--qa/Gemfile2
-rw-r--r--qa/Gemfile.lock10
-rw-r--r--qa/qa/specs/features/browser_ui/6_release/pages/pages_pipeline_spec.rb2
-rw-r--r--rubocop/cop/rspec/web_mock_enable.rb39
-rw-r--r--spec/features/projects/jobs/user_browses_jobs_spec.rb2
-rw-r--r--spec/finders/cluster_ancestors_finder_spec.rb12
-rw-r--r--spec/finders/group_projects_finder_spec.rb38
-rw-r--r--spec/finders/groups_finder_spec.rb23
-rw-r--r--spec/finders/issues_finder_spec.rb58
-rw-r--r--spec/finders/merge_requests_finder_spec.rb16
-rw-r--r--spec/finders/projects_finder_spec.rb2
-rw-r--r--spec/finders/snippets_finder_spec.rb36
-rw-r--r--spec/finders/users_finder_spec.rb2
-rw-r--r--spec/frontend/api_spec.js88
-rw-r--r--spec/frontend/boards/boards_store_spec.js18
-rw-r--r--spec/frontend/boards/components/board_form_spec.js154
-rw-r--r--spec/frontend/fixtures/project_analytics.rb69
-rw-r--r--spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js25
-rw-r--r--spec/frontend/invite_members/components/invite_members_modal_spec.js213
-rw-r--r--spec/frontend/invite_members/components/members_token_select_spec.js28
-rw-r--r--spec/lib/gitlab/analytics/cycle_analytics/stage_events/issue_deployed_to_production_spec.rb7
-rw-r--r--spec/lib/gitlab/data_builder/build_spec.rb1
-rw-r--r--spec/lib/gitlab/data_builder/pipeline_spec.rb1
-rw-r--r--spec/models/ci/build_spec.rb24
-rw-r--r--spec/models/user_spec.rb3
-rw-r--r--spec/rubocop/cop/rspec/web_mock_enable_spec.rb22
-rw-r--r--spec/serializers/build_details_entity_spec.rb8
-rw-r--r--spec/serializers/deploy_key_entity_spec.rb8
-rw-r--r--spec/serializers/runner_entity_spec.rb2
-rw-r--r--spec/spec_helper.rb36
-rw-r--r--spec/workers/group_destroy_worker_spec.rb6
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
diff --git a/Gemfile b/Gemfile
index 5634b465c5f..32aea114d89 100644
--- a/Gemfile
+++ b/Gemfile
@@ -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 }