summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2023-04-14 18:08:53 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2023-04-14 18:08:53 +0000
commit5b62f8e3ee531f63ce3c49cae03e2a618ba51615 (patch)
tree2d2553232fe0663957ee4d1054211cc71cb07679
parentcdb41961fd2bc233d36c5b30f89d087c2efa9818 (diff)
downloadgitlab-ce-5b62f8e3ee531f63ce3c49cae03e2a618ba51615.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--.rubocop_todo/rspec/missing_feature_category.yml1
-rw-r--r--app/assets/javascripts/access_level/constants.js20
-rw-r--r--app/assets/javascripts/api/user_api.js22
-rw-r--r--app/assets/javascripts/content_editor/extensions/paste_markdown.js18
-rw-r--r--app/assets/javascripts/content_editor/services/markdown_sourcemap.js2
-rw-r--r--app/assets/javascripts/deploy_keys/components/key.vue22
-rw-r--r--app/assets/javascripts/deploy_keys/components/keys_panel.vue6
-rw-r--r--app/assets/javascripts/featurable/constants.js6
-rw-r--r--app/assets/javascripts/groups/components/group_item.vue8
-rw-r--r--app/assets/javascripts/groups/components/item_stats.vue16
-rw-r--r--app/assets/javascripts/groups/constants.js35
-rw-r--r--app/assets/javascripts/pages/projects/blob/show/index.js2
-rw-r--r--app/assets/javascripts/profile/components/overview_tab.vue30
-rw-r--r--app/assets/javascripts/profile/components/profile_tabs.vue27
-rw-r--r--app/assets/javascripts/profile/index.js4
-rw-r--r--app/assets/javascripts/repository/components/blob_content_viewer.vue3
-rw-r--r--app/assets/javascripts/repository/index.js13
-rw-r--r--app/assets/javascripts/visibility_level/constants.js32
-rw-r--r--app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue2
-rw-r--r--app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue152
-rw-r--r--app/assets/javascripts/work_items/components/work_item_actions.vue98
-rw-r--r--app/assets/javascripts/work_items/components/work_item_detail.vue5
-rw-r--r--app/assets/javascripts/work_items/constants.js6
-rw-r--r--app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql13
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql5
-rw-r--r--app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql4
-rw-r--r--app/assets/stylesheets/_page_specific_files.scss1
-rw-r--r--app/assets/stylesheets/components/detail_page.scss (renamed from app/assets/stylesheets/pages/detail_page.scss)0
-rw-r--r--app/assets/stylesheets/page_bundles/work_items.scss11
-rw-r--r--app/controllers/groups_controller.rb8
-rw-r--r--app/controllers/projects/blob_controller.rb2
-rw-r--r--app/controllers/projects/deploy_keys_controller.rb2
-rw-r--r--app/controllers/projects/tree_controller.rb2
-rw-r--r--app/controllers/projects_controller.rb2
-rw-r--r--app/experiments/require_verification_for_namespace_creation_experiment.rb22
-rw-r--r--app/graphql/types/work_items/available_export_fields_enum.rb1
-rw-r--r--app/helpers/groups_helper.rb10
-rw-r--r--app/helpers/users_helper.rb3
-rw-r--r--app/serializers/deploy_keys/basic_deploy_key_entity.rb1
-rw-r--r--app/serializers/group_deploy_key_entity.rb1
-rw-r--r--app/services/branches/validate_new_service.rb2
-rw-r--r--app/views/groups/new.html.haml5
-rw-r--r--app/views/projects/_home_panel.html.haml35
-rw-r--r--app/views/shared/deploy_keys/_form.html.haml5
-rw-r--r--app/views/shared/deploy_keys/_project_group_form.html.haml4
-rw-r--r--app/views/shared/projects/_topics.html.haml52
-rw-r--r--config/feature_flags/development/cache_home_panel.yml8
-rw-r--r--config/feature_flags/experiment/require_verification_for_namespace_creation.yml8
-rw-r--r--doc/api/deploy_keys.md25
-rw-r--r--doc/api/graphql/reference/index.md1
-rw-r--r--doc/ci/cloud_services/index.md56
-rw-r--r--doc/ci/secrets/id_token_authentication.md92
-rw-r--r--doc/user/admin_area/review_abuse_reports.md4
-rw-r--r--doc/user/application_security/policies/index.md4
-rw-r--r--doc/user/clusters/agent/ci_cd_workflow.md24
-rw-r--r--doc/user/permissions.md1
-rw-r--r--doc/user/project/codeowners/index.md364
-rw-r--r--doc/user/project/codeowners/reference.md371
-rw-r--r--doc/user/project/deploy_keys/index.md1
-rw-r--r--lib/api/deploy_keys.rb1
-rw-r--r--lib/gitlab/database/migration_helpers.rb1
-rw-r--r--lib/gitlab/database/migration_helpers/wraparound_vacuum_helpers.rb90
-rw-r--r--lib/product_analytics/settings.rb27
-rw-r--r--locale/gitlab.pot30
-rw-r--r--qa/qa/page/project/settings/deploy_keys.rb1
-rw-r--r--spec/controllers/groups_controller_spec.rb23
-rw-r--r--spec/experiments/require_verification_for_namespace_creation_experiment_spec.rb49
-rw-r--r--spec/features/emails/issues_spec.rb128
-rw-r--r--spec/features/issues/user_edits_issue_spec.rb4
-rw-r--r--spec/features/projects/fork_spec.rb28
-rw-r--r--spec/features/projects/settings/repository_settings_spec.rb19
-rw-r--r--spec/features/projects/work_items/work_item_spec.rb11
-rw-r--r--spec/frontend/api/user_api_spec.js23
-rw-r--r--spec/frontend/deploy_keys/components/key_spec.js33
-rw-r--r--spec/frontend/design_management/components/design_notes/design_reply_form_spec.js57
-rw-r--r--spec/frontend/design_management/mock_data/apollo_mock.js102
-rw-r--r--spec/frontend/fixtures/api_projects.rb15
-rw-r--r--spec/frontend/profile/components/overview_tab_spec.js53
-rw-r--r--spec/frontend/profile/components/profile_tabs_spec.js57
-rw-r--r--spec/frontend/vue_shared/components/markdown/editor_mode_dropdown_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js169
-rw-r--r--spec/frontend/vue_shared/components/projects_list/projects_list_spec.js34
-rw-r--r--spec/frontend/work_items/components/work_item_actions_spec.js116
-rw-r--r--spec/frontend/work_items/components/work_item_detail_spec.js1
-rw-r--r--spec/frontend/work_items/mock_data.js9
-rw-r--r--spec/graphql/types/work_items/available_export_fields_enum_spec.rb1
-rw-r--r--spec/helpers/users_helper_spec.rb3
-rw-r--r--spec/lib/gitlab/database/migration_helpers/wraparound_vacuum_helpers_spec.rb99
-rw-r--r--spec/lib/gitlab/database/migration_helpers_spec.rb2
-rw-r--r--spec/lib/product_analytics/settings_spec.rb81
-rw-r--r--spec/mailers/notify_spec.rb110
-rw-r--r--spec/requests/api/deploy_keys_spec.rb25
-rw-r--r--spec/requests/api/graphql/mutations/work_items/export_spec.rb4
-rw-r--r--spec/serializers/deploy_keys/basic_deploy_key_entity_spec.rb1
-rw-r--r--spec/serializers/deploy_keys/deploy_key_entity_spec.rb1
-rw-r--r--spec/serializers/group_deploy_key_entity_spec.rb1
-rw-r--r--spec/services/issues/close_service_spec.rb61
-rw-r--r--spec/support/helpers/content_editor_helpers.rb2
-rw-r--r--spec/support/matchers/have_plain_text_content.rb16
-rw-r--r--spec/support/rspec_order_todo.yml1
-rw-r--r--spec/support/shared_examples/features/content_editor_shared_examples.rb58
-rw-r--r--spec/support/shared_examples/features/deploy_token_shared_examples.rb8
-rw-r--r--spec/support/shared_examples/features/work_items_shared_examples.rb22
-rw-r--r--spec/support/shared_examples/mailers/notify_shared_examples.rb14
105 files changed, 2293 insertions, 1019 deletions
diff --git a/.rubocop_todo/rspec/missing_feature_category.yml b/.rubocop_todo/rspec/missing_feature_category.yml
index a0e239e05b5..31fc6f5d308 100644
--- a/.rubocop_todo/rspec/missing_feature_category.yml
+++ b/.rubocop_todo/rspec/missing_feature_category.yml
@@ -1936,7 +1936,6 @@ RSpec/MissingFeatureCategory:
- 'spec/experiments/force_company_trial_experiment_spec.rb'
- 'spec/experiments/in_product_guidance_environments_webide_experiment_spec.rb'
- 'spec/experiments/ios_specific_templates_experiment_spec.rb'
- - 'spec/experiments/require_verification_for_namespace_creation_experiment_spec.rb'
- 'spec/features/admin/dashboard_spec.rb'
- 'spec/features/groups/integrations/group_integrations_spec.rb'
- 'spec/features/milestones/user_views_milestones_spec.rb'
diff --git a/app/assets/javascripts/access_level/constants.js b/app/assets/javascripts/access_level/constants.js
new file mode 100644
index 00000000000..02a4a3c2f15
--- /dev/null
+++ b/app/assets/javascripts/access_level/constants.js
@@ -0,0 +1,20 @@
+import { __ } from '~/locale';
+
+// Matches `lib/gitlab/access.rb`
+export const ACCESS_LEVEL_NO_ACCESS_INTEGER = 0;
+export const ACCESS_LEVEL_MINIMAL_ACCESS_INTEGER = 5;
+export const ACCESS_LEVEL_GUEST_INTEGER = 10;
+export const ACCESS_LEVEL_REPORTER_INTEGER = 20;
+export const ACCESS_LEVEL_DEVELOPER_INTEGER = 30;
+export const ACCESS_LEVEL_MAINTAINER_INTEGER = 40;
+export const ACCESS_LEVEL_OWNER_INTEGER = 50;
+
+export const ACCESS_LEVEL_LABELS = {
+ [ACCESS_LEVEL_NO_ACCESS_INTEGER]: __('No access'),
+ [ACCESS_LEVEL_MINIMAL_ACCESS_INTEGER]: __('Minimal Access'),
+ [ACCESS_LEVEL_GUEST_INTEGER]: __('Guest'),
+ [ACCESS_LEVEL_REPORTER_INTEGER]: __('Reporter'),
+ [ACCESS_LEVEL_DEVELOPER_INTEGER]: __('Developer'),
+ [ACCESS_LEVEL_MAINTAINER_INTEGER]: __('Maintainer'),
+ [ACCESS_LEVEL_OWNER_INTEGER]: __('Owner'),
+};
diff --git a/app/assets/javascripts/api/user_api.js b/app/assets/javascripts/api/user_api.js
index bcb0f079d3d..3ebb07807d2 100644
--- a/app/assets/javascripts/api/user_api.js
+++ b/app/assets/javascripts/api/user_api.js
@@ -1,6 +1,4 @@
import { DEFAULT_PER_PAGE } from '~/api';
-import { createAlert } from '~/alert';
-import { __ } from '~/locale';
import axios from '../lib/utils/axios_utils';
import { buildApiUrl } from './api_utils';
@@ -44,22 +42,12 @@ export function getUserStatus(id, options) {
});
}
-export function getUserProjects(userId, query, options, callback) {
+export function getUserProjects(userId, options) {
const url = buildApiUrl(USER_PROJECTS_PATH).replace(':id', userId);
- const defaults = {
- search: query,
- per_page: DEFAULT_PER_PAGE,
- };
- return axios
- .get(url, {
- params: { ...defaults, ...options },
- })
- .then(({ data }) => callback(data))
- .catch(() =>
- createAlert({
- message: __('Something went wrong while fetching projects'),
- }),
- );
+
+ return axios.get(url, {
+ params: options,
+ });
}
export function updateUserStatus({ emoji, message, availability, clearStatusAfter }) {
diff --git a/app/assets/javascripts/content_editor/extensions/paste_markdown.js b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
index 44a094d8bf7..82fa5ce6c1d 100644
--- a/app/assets/javascripts/content_editor/extensions/paste_markdown.js
+++ b/app/assets/javascripts/content_editor/extensions/paste_markdown.js
@@ -63,13 +63,29 @@ export default Extension.create({
};
},
addProseMirrorPlugins() {
+ let pasteRaw = false;
+
return [
new Plugin({
key: new PluginKey('pasteMarkdown'),
props: {
- handlePaste: (_, event) => {
+ handleKeyDown: (_, event) => {
+ pasteRaw = event.key === 'v' && (event.metaKey || event.ctrlKey) && event.shiftKey;
+ },
+
+ handlePaste: (view, event) => {
const { clipboardData } = event;
const content = clipboardData.getData(TEXT_FORMAT);
+ const { state } = view;
+ const { tr, selection } = state;
+ const { from, to } = selection;
+
+ if (pasteRaw) {
+ tr.insertText(content.replace(/^\s+|\s+$/gm, ''), from, to);
+ view.dispatch(tr);
+ return true;
+ }
+
const hasHTML = clipboardData.types.some((type) => type === HTML_FORMAT);
const hasVsCode = clipboardData.types.some((type) => type === VS_CODE_FORMAT);
const vsCodeMeta = hasVsCode ? JSON.parse(clipboardData.getData(VS_CODE_FORMAT)) : {};
diff --git a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
index fe1b32c5b0a..11a11ed43bd 100644
--- a/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
+++ b/app/assets/javascripts/content_editor/services/markdown_sourcemap.js
@@ -28,6 +28,8 @@ export const getMarkdownSource = (element) => {
const range = getRangeFromSourcePos(element.dataset.sourcepos);
let elSource = '';
+ if (!source.length) return undefined;
+
for (let i = range.start.row; i <= range.end.row; i += 1) {
if (i === range.start.row) {
elSource += source[i].substring(range.start.col);
diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue
index c9097b9384f..94f27dbf048 100644
--- a/app/assets/javascripts/deploy_keys/components/key.vue
+++ b/app/assets/javascripts/deploy_keys/components/key.vue
@@ -131,7 +131,7 @@ export default {
</dl>
</div>
</div>
- <div class="table-section section-30 section-wrap">
+ <div class="table-section section-20 section-wrap">
<div role="rowheader" class="table-mobile-header">{{ s__('DeployKeys|Project usage') }}</div>
<div class="table-mobile-content deploy-project-list">
<template v-if="projects.length > 0">
@@ -168,7 +168,7 @@ export default {
<span v-else class="text-secondary">{{ __('None') }}</span>
</div>
</div>
- <div class="table-section section-15 text-right">
+ <div class="table-section section-15">
<div role="rowheader" class="table-mobile-header">{{ __('Created') }}</div>
<div class="table-mobile-content text-secondary key-created-at">
<span v-gl-tooltip :title="tooltipTitle(deployKey.created_at)">
@@ -176,7 +176,23 @@ export default {
</span>
</div>
</div>
- <div class="table-section section-15 table-button-footer deploy-key-actions">
+ <div class="table-section section-15">
+ <div role="rowheader" class="table-mobile-header">{{ __('Expires') }}</div>
+ <div class="table-mobile-content text-secondary key-expires-at">
+ <span
+ v-if="deployKey.expires_at"
+ v-gl-tooltip
+ :title="tooltipTitle(deployKey.expires_at)"
+ data-testid="expires-at-tooltip"
+ >
+ <gl-icon name="calendar" /> <span>{{ timeFormatted(deployKey.expires_at) }}</span>
+ </span>
+ <span v-else>
+ <span data-testid="expires-never">{{ __('Never') }}</span>
+ </span>
+ </div>
+ </div>
+ <div class="table-section section-10 table-button-footer deploy-key-actions">
<div class="btn-group table-action-buttons">
<action-btn v-if="!isEnabled" :deploy-key="deployKey" type="enable" category="secondary">
{{ __('Enable') }}
diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
index 77ec1ef590f..e04cbbe72b9 100644
--- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue
+++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue
@@ -34,10 +34,12 @@ export default {
<div role="rowheader" class="table-section section-40">
{{ s__('DeployKeys|Deploy key') }}
</div>
- <div role="rowheader" class="table-section section-30">
+ <div role="rowheader" class="table-section section-20">
{{ s__('DeployKeys|Project usage') }}
</div>
- <div role="rowheader" class="table-section section-15 text-right">{{ __('Created') }}</div>
+ <div role="rowheader" class="table-section section-15">{{ __('Created') }}</div>
+ <div role="rowheader" class="table-section section-15">{{ __('Expires') }}</div>
+ <!-- leave 10% space for actions --->
</div>
<deploy-key
v-for="deployKey in keys"
diff --git a/app/assets/javascripts/featurable/constants.js b/app/assets/javascripts/featurable/constants.js
new file mode 100644
index 00000000000..23f1c5e415d
--- /dev/null
+++ b/app/assets/javascripts/featurable/constants.js
@@ -0,0 +1,6 @@
+// Matches `app/models/concerns/featurable.rb`
+
+export const FEATURABLE_DISABLED = 'disabled';
+export const FEATURABLE_PRIVATE = 'private';
+export const FEATURABLE_ENABLED = 'enabled';
+export const FEATURABLE_PUBLIC = 'public';
diff --git a/app/assets/javascripts/groups/components/group_item.vue b/app/assets/javascripts/groups/components/group_item.vue
index d9781ef9c84..8d202194de7 100644
--- a/app/assets/javascripts/groups/components/group_item.vue
+++ b/app/assets/javascripts/groups/components/group_item.vue
@@ -16,8 +16,12 @@ import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import { helpPagePath } from '~/helpers/help_page_helper';
import { __ } from '~/locale';
-import { VISIBILITY_LEVELS_STRING_TO_INTEGER } from '~/visibility_level/constants';
-import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE, ITEM_TYPE } from '../constants';
+import {
+ VISIBILITY_LEVELS_STRING_TO_INTEGER,
+ VISIBILITY_TYPE_ICON,
+ GROUP_VISIBILITY_TYPE,
+} from '~/visibility_level/constants';
+import { ITEM_TYPE } from '../constants';
import eventHub from '../event_hub';
diff --git a/app/assets/javascripts/groups/components/item_stats.vue b/app/assets/javascripts/groups/components/item_stats.vue
index a4c163b0a81..5674e28f5da 100644
--- a/app/assets/javascripts/groups/components/item_stats.vue
+++ b/app/assets/javascripts/groups/components/item_stats.vue
@@ -2,12 +2,7 @@
import { GlBadge } from '@gitlab/ui';
import isProjectPendingRemoval from 'ee_else_ce/groups/mixins/is_project_pending_removal';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
-import {
- ITEM_TYPE,
- VISIBILITY_TYPE_ICON,
- GROUP_VISIBILITY_TYPE,
- PROJECT_VISIBILITY_TYPE,
-} from '../constants';
+import { ITEM_TYPE } from '../constants';
import ItemStatsValue from './item_stats_value.vue';
export default {
@@ -24,15 +19,6 @@ export default {
},
},
computed: {
- visibilityIcon() {
- return VISIBILITY_TYPE_ICON[this.item.visibility];
- },
- visibilityTooltip() {
- if (this.item.type === ITEM_TYPE.GROUP) {
- return GROUP_VISIBILITY_TYPE[this.item.visibility];
- }
- return PROJECT_VISIBILITY_TYPE[this.item.visibility];
- },
isProject() {
return this.item.type === ITEM_TYPE.PROJECT;
},
diff --git a/app/assets/javascripts/groups/constants.js b/app/assets/javascripts/groups/constants.js
index 6f5b03788a8..a5854632040 100644
--- a/app/assets/javascripts/groups/constants.js
+++ b/app/assets/javascripts/groups/constants.js
@@ -1,9 +1,4 @@
import { __, s__ } from '~/locale';
-import {
- VISIBILITY_LEVEL_PRIVATE_STRING,
- VISIBILITY_LEVEL_INTERNAL_STRING,
- VISIBILITY_LEVEL_PUBLIC_STRING,
-} from '~/visibility_level/constants';
export const MAX_CHILDREN_COUNT = 20;
@@ -30,36 +25,6 @@ export const ITEM_TYPE = {
GROUP: 'group',
};
-export const GROUP_VISIBILITY_TYPE = {
- [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
- 'Public - The group and any public projects can be viewed without any authentication.',
- ),
- [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
- 'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
- ),
- [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
- 'Private - The group and its projects can only be viewed by members.',
- ),
-};
-
-export const PROJECT_VISIBILITY_TYPE = {
- [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
- 'Public - The project can be accessed without any authentication.',
- ),
- [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
- 'Internal - The project can be accessed by any logged in user except external users.',
- ),
- [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
- 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
- ),
-};
-
-export const VISIBILITY_TYPE_ICON = {
- [VISIBILITY_LEVEL_PUBLIC_STRING]: 'earth',
- [VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield',
- [VISIBILITY_LEVEL_PRIVATE_STRING]: 'lock',
-};
-
export const OVERVIEW_TABS_SORTING_ITEMS = [
{
label: __('Name'),
diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js
index 7ec56b29c88..ec894586803 100644
--- a/app/assets/javascripts/pages/projects/blob/show/index.js
+++ b/app/assets/javascripts/pages/projects/blob/show/index.js
@@ -68,6 +68,7 @@ if (viewBlobEl) {
originalBranch,
resourceId,
userId,
+ explainCodeAvailable,
} = viewBlobEl.dataset;
// eslint-disable-next-line no-new
@@ -81,6 +82,7 @@ if (viewBlobEl) {
originalBranch,
resourceId,
userId,
+ explainCodeAvailable: parseBoolean(explainCodeAvailable),
},
render(createElement) {
return createElement(BlobContentViewer, {
diff --git a/app/assets/javascripts/profile/components/overview_tab.vue b/app/assets/javascripts/profile/components/overview_tab.vue
index 76fb13919df..8eede317344 100644
--- a/app/assets/javascripts/profile/components/overview_tab.vue
+++ b/app/assets/javascripts/profile/components/overview_tab.vue
@@ -1,18 +1,44 @@
<script>
-import { GlTab } from '@gitlab/ui';
+import { GlTab, GlLoadingIcon, GlLink } from '@gitlab/ui';
import { s__ } from '~/locale';
+import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue';
import ActivityCalendar from './activity_calendar.vue';
export default {
i18n: {
title: s__('UserProfile|Overview'),
+ personalProjects: s__('UserProfile|Personal projects'),
+ viewAll: s__('UserProfile|View all'),
+ },
+ components: { GlTab, GlLoadingIcon, GlLink, ActivityCalendar, ProjectsList },
+ props: {
+ personalProjects: {
+ type: Array,
+ required: true,
+ },
+ personalProjectsLoading: {
+ type: Boolean,
+ required: true,
+ },
},
- components: { GlTab, ActivityCalendar },
};
</script>
<template>
<gl-tab :title="$options.i18n.title">
<activity-calendar />
+ <div class="gl-mx-n3 gl-display-flex gl-flex-wrap-wrap">
+ <div class="gl-px-3 gl-w-full gl-lg-w-half"></div>
+ <div class="gl-px-3 gl-w-full gl-lg-w-half" data-testid="personal-projects-section">
+ <div
+ class="gl-display-flex gl-align-items-center gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid"
+ >
+ <h4 class="gl-flex-grow-1">{{ $options.i18n.personalProjects }}</h4>
+ <gl-link href="">{{ $options.i18n.viewAll }}</gl-link>
+ </div>
+ <gl-loading-icon v-if="personalProjectsLoading" class="gl-mt-5" size="md" />
+ <projects-list v-else :projects="personalProjects" />
+ </div>
+ </div>
</gl-tab>
</template>
diff --git a/app/assets/javascripts/profile/components/profile_tabs.vue b/app/assets/javascripts/profile/components/profile_tabs.vue
index b39bfabb832..25b94d7dc7f 100644
--- a/app/assets/javascripts/profile/components/profile_tabs.vue
+++ b/app/assets/javascripts/profile/components/profile_tabs.vue
@@ -1,6 +1,10 @@
<script>
import { GlTabs } from '@gitlab/ui';
+import { getUserProjects } from '~/rest_api';
+import { s__ } from '~/locale';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { createAlert } from '~/alert';
import OverviewTab from './overview_tab.vue';
import ActivityTab from './activity_tab.vue';
import GroupsTab from './groups_tab.vue';
@@ -12,6 +16,11 @@ import FollowersTab from './followers_tab.vue';
import FollowingTab from './following_tab.vue';
export default {
+ i18n: {
+ personalProjectsErrorMessage: s__(
+ 'UserProfile|An error occurred loading the personal projects. Please refresh the page to try again.',
+ ),
+ },
components: {
GlTabs,
OverviewTab,
@@ -62,6 +71,22 @@ export default {
component: FollowingTab,
},
],
+ inject: ['userId'],
+ data() {
+ return {
+ personalProjectsLoading: true,
+ personalProjects: [],
+ };
+ },
+ async mounted() {
+ try {
+ const response = await getUserProjects(this.userId, { per_page: 10 });
+ this.personalProjects = convertObjectPropsToCamelCase(response.data, { deep: true });
+ this.personalProjectsLoading = false;
+ } catch (error) {
+ createAlert({ message: this.$options.i18n.personalProjectsErrorMessage });
+ }
+ },
};
</script>
@@ -72,6 +97,8 @@ export default {
v-for="{ key, component } in $options.tabs"
:key="key"
class="container-fluid container-limited"
+ :personal-projects="personalProjects"
+ :personal-projects-loading="personalProjectsLoading"
/>
</gl-tabs>
</template>
diff --git a/app/assets/javascripts/profile/index.js b/app/assets/javascripts/profile/index.js
index fbe0e3534d8..101e52c873e 100644
--- a/app/assets/javascripts/profile/index.js
+++ b/app/assets/javascripts/profile/index.js
@@ -13,15 +13,17 @@ export const initProfileTabs = () => {
if (!el) return false;
- const { followees, followers, userCalendarPath, utcOffset } = el.dataset;
+ const { followees, followers, userCalendarPath, utcOffset, userId } = el.dataset;
return new Vue({
el,
+ name: 'ProfileRoot',
provide: {
followees: parseInt(followers, 10),
followers: parseInt(followees, 10),
userCalendarPath,
utcOffset,
+ userId,
},
render(createElement) {
return createElement(ProfileTabs);
diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue
index 4ce714f7c21..334e7964bc2 100644
--- a/app/assets/javascripts/repository/components/blob_content_viewer.vue
+++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue
@@ -41,6 +41,7 @@ export default {
originalBranch: {
default: '',
},
+ explainCodeAvailable: { default: false },
},
apollo: {
projectInfo: {
@@ -144,7 +145,7 @@ export default {
},
computed: {
shouldRenderGenie() {
- return this.glFeatures.explainCode && this.glFeatures.explainCodeSnippet && this.isLoggedIn;
+ return this.explainCodeAvailable;
},
isLoggedIn() {
return isLoggedIn();
diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js
index 294c0c13648..5a3958d8e4a 100644
--- a/app/assets/javascripts/repository/index.js
+++ b/app/assets/javascripts/repository/index.js
@@ -32,7 +32,16 @@ Vue.use(PerformancePlugin, {
export default function setupVueRepositoryList() {
const el = document.getElementById('js-tree-list');
const { dataset } = el;
- const { projectPath, projectShortPath, ref, escapedRef, fullName, resourceId, userId } = dataset;
+ const {
+ projectPath,
+ projectShortPath,
+ ref,
+ escapedRef,
+ fullName,
+ resourceId,
+ userId,
+ explainCodeAvailable,
+ } = dataset;
const router = createRouter(projectPath, escapedRef);
apolloProvider.clients.defaultClient.cache.writeQuery({
@@ -281,7 +290,7 @@ export default function setupVueRepositoryList() {
store: createStore(),
router,
apolloProvider,
- provide: { resourceId, userId },
+ provide: { resourceId, userId, explainCodeAvailable: parseBoolean(explainCodeAvailable) },
render(h) {
return h(App);
},
diff --git a/app/assets/javascripts/visibility_level/constants.js b/app/assets/javascripts/visibility_level/constants.js
index 77736fb6ef5..e30982985b3 100644
--- a/app/assets/javascripts/visibility_level/constants.js
+++ b/app/assets/javascripts/visibility_level/constants.js
@@ -1,3 +1,5 @@
+import { __ } from '~/locale';
+
export const VISIBILITY_LEVEL_PRIVATE_STRING = 'private';
export const VISIBILITY_LEVEL_INTERNAL_STRING = 'internal';
export const VISIBILITY_LEVEL_PUBLIC_STRING = 'public';
@@ -18,3 +20,33 @@ export const VISIBILITY_LEVELS_INTEGER_TO_STRING = {
[VISIBILITY_LEVEL_INTERNAL_INTEGER]: VISIBILITY_LEVEL_INTERNAL_STRING,
[VISIBILITY_LEVEL_PUBLIC_INTEGER]: VISIBILITY_LEVEL_PUBLIC_STRING,
};
+
+export const GROUP_VISIBILITY_TYPE = {
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
+ 'Public - The group and any public projects can be viewed without any authentication.',
+ ),
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
+ 'Internal - The group and any internal projects can be viewed by any logged in user except external users.',
+ ),
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
+ 'Private - The group and its projects can only be viewed by members.',
+ ),
+};
+
+export const PROJECT_VISIBILITY_TYPE = {
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: __(
+ 'Public - The project can be accessed without any authentication.',
+ ),
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: __(
+ 'Internal - The project can be accessed by any logged in user except external users.',
+ ),
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: __(
+ 'Private - Project access must be granted explicitly to each user. If this project is part of a group, access will be granted to members of the group.',
+ ),
+};
+
+export const VISIBILITY_TYPE_ICON = {
+ [VISIBILITY_LEVEL_PUBLIC_STRING]: 'earth',
+ [VISIBILITY_LEVEL_INTERNAL_STRING]: 'shield',
+ [VISIBILITY_LEVEL_PRIVATE_STRING]: 'lock',
+};
diff --git a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue
index 9ebf782a1d9..7803d6f53e0 100644
--- a/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/markdown/editor_mode_dropdown.vue
@@ -23,7 +23,7 @@ export default {
return this.value === 'markdown';
},
text() {
- return this.markdownEditorSelected ? __('Viewing markdown') : __('Viewing rich text');
+ return this.markdownEditorSelected ? __('Editing markdown') : __('Editing rich text');
},
},
};
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue
new file mode 100644
index 00000000000..1ace1c52a68
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list.vue
@@ -0,0 +1,37 @@
+<script>
+import ProjectsListItem from './projects_list_item.vue';
+
+export default {
+ components: { ProjectsListItem },
+ props: {
+ /**
+ * Expected format:
+ *
+ * {
+ * id: number | string;
+ * name: string;
+ * webUrl: string;
+ * forksCount?: number;
+ * avatarUrl: string | null;
+ * starCount: number;
+ * visibility: string;
+ * issuesAccessLevel: string;
+ * forkingAccessLevel: string;
+ * openIssuesCount: number;
+ * permissions: {
+ * projectAccess: { accessLevel: 50 };
+ * }[];
+ */
+ projects: {
+ type: Array,
+ required: true,
+ },
+ },
+};
+</script>
+
+<template>
+ <ul class="gl-p-0 gl-list-style-none">
+ <projects-list-item v-for="project in projects" :key="project.id" :project="project" />
+ </ul>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
new file mode 100644
index 00000000000..f77fd029e93
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/projects_list/projects_list_item.vue
@@ -0,0 +1,152 @@
+<script>
+import { GlAvatarLabeled, GlIcon, GlLink, GlBadge, GlTooltipDirective } from '@gitlab/ui';
+
+import { VISIBILITY_TYPE_ICON, PROJECT_VISIBILITY_TYPE } from '~/visibility_level/constants';
+import { ACCESS_LEVEL_LABELS } from '~/access_level/constants';
+import { FEATURABLE_ENABLED } from '~/featurable/constants';
+import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
+import { __ } from '~/locale';
+import { numberToMetricPrefix } from '~/lib/utils/number_utils';
+
+export default {
+ i18n: {
+ stars: __('Stars'),
+ forks: __('Forks'),
+ issues: __('Issues'),
+ archived: __('Archived'),
+ },
+ components: {
+ GlAvatarLabeled,
+ GlIcon,
+ UserAccessRoleBadge,
+ GlLink,
+ GlBadge,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ props: {
+ /**
+ * Expected format:
+ *
+ * {
+ * id: number | string;
+ * name: string;
+ * webUrl: string;
+ * forksCount?: number;
+ * avatarUrl: string | null;
+ * starCount: number;
+ * visibility: string;
+ * issuesAccessLevel: string;
+ * forkingAccessLevel: string;
+ * openIssuesCount: number;
+ * permissions: {
+ * projectAccess: { accessLevel: 50 };
+ * };
+ */
+ project: {
+ type: Object,
+ required: true,
+ },
+ },
+ computed: {
+ visibilityIcon() {
+ return VISIBILITY_TYPE_ICON[this.project.visibility];
+ },
+ visibilityTooltip() {
+ return PROJECT_VISIBILITY_TYPE[this.project.visibility];
+ },
+ accessLevel() {
+ return this.project.permissions?.projectAccess?.accessLevel;
+ },
+ accessLevelLabel() {
+ return ACCESS_LEVEL_LABELS[this.accessLevel];
+ },
+ shouldShowAccessLevel() {
+ return this.accessLevel !== undefined;
+ },
+ starsHref() {
+ return `${this.project.webUrl}/-/starrers`;
+ },
+ forksHref() {
+ return `${this.project.webUrl}/-/forks`;
+ },
+ issuesHref() {
+ return `${this.project.webUrl}/-/issues`;
+ },
+ isForkingEnabled() {
+ return (
+ this.project.forkingAccessLevel === FEATURABLE_ENABLED &&
+ this.project.forksCount !== undefined
+ );
+ },
+ isIssuesEnabled() {
+ return this.project.issuesAccessLevel === FEATURABLE_ENABLED;
+ },
+ },
+ methods: {
+ numberToMetricPrefix,
+ },
+};
+</script>
+
+<template>
+ <li class="gl-py-5 gl-md-display-flex gl-align-items-center gl-border-b">
+ <gl-avatar-labeled
+ class="gl-flex-grow-1"
+ :entity-id="project.id"
+ :entity-name="project.name"
+ :label="project.name"
+ :label-link="project.webUrl"
+ shape="rect"
+ :size="48"
+ >
+ <template #meta>
+ <gl-icon
+ v-gl-tooltip="visibilityTooltip"
+ :name="visibilityIcon"
+ class="gl-text-secondary gl-ml-3"
+ />
+ <user-access-role-badge v-if="shouldShowAccessLevel" class="gl-ml-3">{{
+ accessLevelLabel
+ }}</user-access-role-badge>
+ </template>
+ </gl-avatar-labeled>
+ <div
+ class="gl-md-display-flex gl-flex-direction-column gl-align-items-flex-end gl-flex-shrink-0 gl-mt-3 gl-md-mt-0"
+ >
+ <div class="gl-display-flex gl-align-items-center gl-gap-x-3">
+ <gl-badge v-if="project.archived" variant="warning">{{ $options.i18n.archived }}</gl-badge>
+ <gl-link
+ v-gl-tooltip="$options.i18n.stars"
+ :href="starsHref"
+ :aria-label="$options.i18n.stars"
+ class="gl-text-secondary"
+ >
+ <gl-icon name="star-o" />
+ <span>{{ numberToMetricPrefix(project.starCount) }}</span>
+ </gl-link>
+ <gl-link
+ v-if="isForkingEnabled"
+ v-gl-tooltip="$options.i18n.forks"
+ :href="forksHref"
+ :aria-label="$options.i18n.forks"
+ class="gl-text-secondary"
+ >
+ <gl-icon name="fork" />
+ <span>{{ numberToMetricPrefix(project.forksCount) }}</span>
+ </gl-link>
+ <gl-link
+ v-if="isIssuesEnabled"
+ v-gl-tooltip="$options.i18n.issues"
+ :href="issuesHref"
+ :aria-label="$options.i18n.issues"
+ class="gl-text-secondary"
+ >
+ <gl-icon name="issues" />
+ <span>{{ numberToMetricPrefix(project.openIssuesCount) }}</span>
+ </gl-link>
+ </div>
+ </div>
+ </li>
+</template>
diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue
index 3c56b627673..5dfae18b698 100644
--- a/app/assets/javascripts/work_items/components/work_item_actions.vue
+++ b/app/assets/javascripts/work_items/components/work_item_actions.vue
@@ -2,33 +2,53 @@
import {
GlDropdown,
GlDropdownItem,
+ GlDropdownForm,
GlDropdownDivider,
GlModal,
GlModalDirective,
+ GlToggle,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import Tracking from '~/tracking';
+import toast from '~/vue_shared/plugins/global_toast';
+import { isLoggedIn } from '~/lib/utils/common_utils';
import {
sprintfWorkItem,
I18N_WORK_ITEM_DELETE,
I18N_WORK_ITEM_ARE_YOU_SURE_DELETE,
+ TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION,
+ TEST_ID_NOTIFICATIONS_TOGGLE_ACTION,
+ TEST_ID_NOTIFICATIONS_TOGGLE_FORM,
+ TEST_ID_DELETE_ACTION,
+ WIDGET_TYPE_NOTIFICATIONS,
} from '../constants';
+import updateWorkItemNotificationsMutation from '../graphql/update_work_item_notifications.mutation.graphql';
export default {
i18n: {
enableTaskConfidentiality: s__('WorkItem|Turn on confidentiality'),
disableTaskConfidentiality: s__('WorkItem|Turn off confidentiality'),
+ notifications: s__('WorkItem|Notifications'),
+ notificationOn: s__('WorkItem|Notifications turned on.'),
+ notificationOff: s__('WorkItem|Notifications turned off.'),
},
components: {
GlDropdown,
GlDropdownItem,
+ GlDropdownForm,
GlDropdownDivider,
GlModal,
+ GlToggle,
},
directives: {
GlModal: GlModalDirective,
},
mixins: [Tracking.mixin({ label: 'actions_menu' })],
+ isLoggedIn: isLoggedIn(),
+ notificationsToggleTestId: TEST_ID_NOTIFICATIONS_TOGGLE_ACTION,
+ notificationsToggleFormTestId: TEST_ID_NOTIFICATIONS_TOGGLE_FORM,
+ confidentialityTestId: TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION,
+ deleteActionTestId: TEST_ID_DELETE_ACTION,
props: {
workItemId: {
type: String,
@@ -60,8 +80,12 @@ export default {
required: false,
default: false,
},
+ subscribedToNotifications: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
},
- emits: ['deleteWorkItem', 'toggleWorkItemConfidentiality'],
computed: {
i18n() {
return {
@@ -84,6 +108,56 @@ export default {
this.track('cancel_delete_work_item');
}
},
+ toggleNotifications(subscribed) {
+ const inputVariables = {
+ id: this.workItemId,
+ notificationsWidget: {
+ subscribed,
+ },
+ };
+ this.$apollo
+ .mutate({
+ mutation: updateWorkItemNotificationsMutation,
+ variables: {
+ input: inputVariables,
+ },
+ optimisticResponse: {
+ workItemUpdate: {
+ errors: [],
+ workItem: {
+ id: this.workItemId,
+ widgets: [
+ {
+ type: WIDGET_TYPE_NOTIFICATIONS,
+ subscribed,
+ __typename: 'WorkItemWidgetNotifications',
+ },
+ ],
+ __typename: 'WorkItem',
+ },
+ __typename: 'WorkItemUpdatePayload',
+ },
+ },
+ })
+ .then(
+ ({
+ data: {
+ workItemUpdate: { errors },
+ },
+ }) => {
+ if (errors?.length) {
+ throw new Error(errors[0]);
+ }
+ toast(
+ subscribed ? this.$options.i18n.notificationOn : this.$options.i18n.notificationOff,
+ );
+ },
+ )
+ .catch((error) => {
+ this.updateError = error.message;
+ this.$emit('error', error.message);
+ });
+ },
},
};
</script>
@@ -99,9 +173,27 @@ export default {
no-caret
right
>
+ <template v-if="$options.isLoggedIn">
+ <gl-dropdown-form
+ class="work-item-notifications-form"
+ :data-testid="$options.notificationsToggleFormTestId"
+ >
+ <div class="gl-px-5 gl-pb-2 gl-pt-1">
+ <gl-toggle
+ :value="subscribedToNotifications"
+ :label="$options.i18n.notifications"
+ :data-testid="$options.notificationsToggleTestId"
+ label-position="left"
+ label-id="notifications-toggle"
+ @change="toggleNotifications($event)"
+ />
+ </div>
+ </gl-dropdown-form>
+ <gl-dropdown-divider />
+ </template>
<template v-if="canUpdate && !isParentConfidential">
<gl-dropdown-item
- data-testid="confidentiality-toggle-action"
+ :data-testid="$options.confidentialityTestId"
@click="handleToggleWorkItemConfidentiality"
>{{
isConfidential
@@ -114,7 +206,7 @@ export default {
<gl-dropdown-item
v-if="canDelete"
v-gl-modal="'work-item-confirm-delete'"
- data-testid="delete-action"
+ :data-testid="$options.deleteActionTestId"
variant="danger"
>{{ i18n.deleteWorkItem }}</gl-dropdown-item
>
diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue
index 738305ad670..06e8a65ecf7 100644
--- a/app/assets/javascripts/work_items/components/work_item_detail.vue
+++ b/app/assets/javascripts/work_items/components/work_item_detail.vue
@@ -26,6 +26,7 @@ import {
i18n,
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_LABELS,
+ WIDGET_TYPE_NOTIFICATIONS,
WIDGET_TYPE_DESCRIPTION,
WIDGET_TYPE_START_AND_DUE_DATE,
WIDGET_TYPE_WEIGHT,
@@ -271,6 +272,9 @@ export default {
hasDescriptionWidget() {
return this.isWidgetPresent(WIDGET_TYPE_DESCRIPTION);
},
+ workItemNotificationsSubscribed() {
+ return Boolean(this.isWidgetPresent(WIDGET_TYPE_NOTIFICATIONS)?.subscribed);
+ },
workItemAssignees() {
return this.isWidgetPresent(WIDGET_TYPE_ASSIGNEES);
},
@@ -557,6 +561,7 @@ export default {
<work-item-actions
v-if="canUpdate || canDelete"
:work-item-id="workItem.id"
+ :subscribed-to-notifications="workItemNotificationsSubscribed"
:work-item-type="workItemType"
:can-delete="canDelete"
:can-update="canUpdate"
diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js
index bbcf78e23aa..6af4f0fe790 100644
--- a/app/assets/javascripts/work_items/constants.js
+++ b/app/assets/javascripts/work_items/constants.js
@@ -14,6 +14,7 @@ export const TASK_TYPE_NAME = 'Task';
export const WIDGET_TYPE_ASSIGNEES = 'ASSIGNEES';
export const WIDGET_TYPE_DESCRIPTION = 'DESCRIPTION';
+export const WIDGET_TYPE_NOTIFICATIONS = 'NOTIFICATIONS';
export const WIDGET_TYPE_LABELS = 'LABELS';
export const WIDGET_TYPE_START_AND_DUE_DATE = 'START_AND_DUE_DATE';
export const WIDGET_TYPE_WEIGHT = 'WEIGHT';
@@ -205,3 +206,8 @@ export const WORK_ITEM_ACTIVITY_SORT_OPTIONS = [
{ key: DESC, text: __('Newest first'), testid: 'newest-first' },
{ key: ASC, text: __('Oldest first') },
];
+
+export const TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION = 'confidentiality-toggle-action';
+export const TEST_ID_NOTIFICATIONS_TOGGLE_ACTION = 'notifications-toggle-action';
+export const TEST_ID_NOTIFICATIONS_TOGGLE_FORM = 'notifications-toggle-form';
+export const TEST_ID_DELETE_ACTION = 'delete-action';
diff --git a/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql
new file mode 100644
index 00000000000..f8952b62f28
--- /dev/null
+++ b/app/assets/javascripts/work_items/graphql/update_work_item_notifications.mutation.graphql
@@ -0,0 +1,13 @@
+mutation updateWorkItemNotificationsWidget($input: WorkItemUpdateInput!) {
+ workItemUpdate(input: $input) {
+ workItem {
+ id
+ widgets {
+ ... on WorkItemWidgetNotifications {
+ type
+ subscribed
+ }
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
index b5d27231bef..44fda3ee894 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_metadata_widgets.fragment.graphql
@@ -36,4 +36,9 @@ fragment WorkItemMetadataWidgets on WorkItemWidget {
}
}
}
+
+ ... on WorkItemWidgetNotifications {
+ type
+ subscribed
+ }
}
diff --git a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
index bf8eafe3211..8039ef53f98 100644
--- a/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
+++ b/app/assets/javascripts/work_items/graphql/work_item_widgets.fragment.graphql
@@ -85,4 +85,8 @@ fragment WorkItemWidgets on WorkItemWidget {
... on WorkItemWidgetNotes {
type
}
+ ... on WorkItemWidgetNotifications {
+ type
+ subscribed
+ }
}
diff --git a/app/assets/stylesheets/_page_specific_files.scss b/app/assets/stylesheets/_page_specific_files.scss
index cd626f449d9..483c4dc226b 100644
--- a/app/assets/stylesheets/_page_specific_files.scss
+++ b/app/assets/stylesheets/_page_specific_files.scss
@@ -1,6 +1,5 @@
@import './pages/colors';
@import './pages/commits';
-@import './pages/detail_page';
@import './pages/events';
@import './pages/groups';
@import './pages/hierarchy';
diff --git a/app/assets/stylesheets/pages/detail_page.scss b/app/assets/stylesheets/components/detail_page.scss
index de8142924f9..de8142924f9 100644
--- a/app/assets/stylesheets/pages/detail_page.scss
+++ b/app/assets/stylesheets/components/detail_page.scss
diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss
index 5f6883623b2..ecbb872e1df 100644
--- a/app/assets/stylesheets/page_bundles/work_items.scss
+++ b/app/assets/stylesheets/page_bundles/work_items.scss
@@ -93,3 +93,14 @@
top: -8px;
}
}
+
+
+.work-item-notifications-form {
+ .gl-toggle {
+ @include gl-ml-auto;
+ }
+
+ .gl-toggle-label {
+ @include gl-font-weight-normal;
+ }
+}
diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb
index a0c82998108..bd6d9b835c3 100644
--- a/app/controllers/groups_controller.rb
+++ b/app/controllers/groups_controller.rb
@@ -32,8 +32,6 @@ class GroupsController < Groups::ApplicationController
before_action :check_export_rate_limit!, only: [:export, :download_export]
- before_action :track_experiment_event, only: [:new]
-
before_action only: :issues do
push_frontend_feature_flag(:or_issuable_queries, group)
push_frontend_feature_flag(:frontend_caching, group)
@@ -402,12 +400,6 @@ class GroupsController < Groups::ApplicationController
captcha_enabled? && !params[:parent_id]
end
- def track_experiment_event
- return if params[:parent_id]
-
- experiment(:require_verification_for_namespace_creation, user: current_user).track(:start_create_group)
- end
-
def group_feature_attributes
[]
end
diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb
index 5173abfbfd5..53c6676b62b 100644
--- a/app/controllers/projects/blob_controller.rb
+++ b/app/controllers/projects/blob_controller.rb
@@ -49,8 +49,6 @@ class Projects::BlobController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:highlight_js, @project)
- push_frontend_feature_flag(:explain_code_snippet, current_user)
- push_licensed_feature(:explain_code, @project) if @project.licensed_feature_available?(:explain_code)
push_frontend_feature_flag(:synchronize_fork, @project&.fork_source)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
end
diff --git a/app/controllers/projects/deploy_keys_controller.rb b/app/controllers/projects/deploy_keys_controller.rb
index 22a42d22914..9cdbd2a30f6 100644
--- a/app/controllers/projects/deploy_keys_controller.rb
+++ b/app/controllers/projects/deploy_keys_controller.rb
@@ -82,7 +82,7 @@ class Projects::DeployKeysController < Projects::ApplicationController
def create_params
create_params = params.require(:deploy_key)
- .permit(:key, :title, deploy_keys_projects_attributes: [:can_push])
+ .permit(:key, :title, :expires_at, deploy_keys_projects_attributes: [:can_push])
create_params.dig(:deploy_keys_projects_attributes, '0')&.merge!(project_id: @project.id)
create_params
end
diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb
index 367417ba840..0631c02355e 100644
--- a/app/controllers/projects/tree_controller.rb
+++ b/app/controllers/projects/tree_controller.rb
@@ -19,8 +19,6 @@ class Projects::TreeController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:highlight_js, @project)
push_frontend_feature_flag(:synchronize_fork, @project.fork_source)
- push_frontend_feature_flag(:explain_code_snippet, current_user)
- push_licensed_feature(:explain_code, @project) if @project.licensed_feature_available?(:explain_code)
push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks)
end
diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb
index be30255ec4e..a6bc754d09e 100644
--- a/app/controllers/projects_controller.rb
+++ b/app/controllers/projects_controller.rb
@@ -39,10 +39,8 @@ class ProjectsController < Projects::ApplicationController
before_action do
push_frontend_feature_flag(:highlight_js, @project)
push_frontend_feature_flag(:synchronize_fork, @project&.fork_source)
- push_frontend_feature_flag(:explain_code_snippet, current_user)
push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks)
push_licensed_feature(:security_orchestration_policies) if @project.present? && @project.licensed_feature_available?(:security_orchestration_policies)
- push_licensed_feature(:explain_code, @project) if @project.present? && @project.licensed_feature_available?(:explain_code)
push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc, @project&.work_items_mvc_feature_flag_enabled?)
push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?)
diff --git a/app/experiments/require_verification_for_namespace_creation_experiment.rb b/app/experiments/require_verification_for_namespace_creation_experiment.rb
deleted file mode 100644
index 914c5c4a29e..00000000000
--- a/app/experiments/require_verification_for_namespace_creation_experiment.rb
+++ /dev/null
@@ -1,22 +0,0 @@
-# frozen_string_literal: true
-
-class RequireVerificationForNamespaceCreationExperiment < ApplicationExperiment
- control { false }
- candidate { true }
-
- exclude :existing_user
-
- EXPERIMENT_START_DATE = Date.new(2022, 1, 31)
-
- def candidate?
- run
- end
-
- private
-
- def existing_user
- return false unless user_or_actor
-
- user_or_actor.created_at < EXPERIMENT_START_DATE
- end
-end
diff --git a/app/graphql/types/work_items/available_export_fields_enum.rb b/app/graphql/types/work_items/available_export_fields_enum.rb
index 59dd7ba89b1..f5b26d9818d 100644
--- a/app/graphql/types/work_items/available_export_fields_enum.rb
+++ b/app/graphql/types/work_items/available_export_fields_enum.rb
@@ -8,6 +8,7 @@ module Types
value 'ID', value: 'id', description: 'Unique identifier.'
value 'TITLE', value: 'title', description: 'Title.'
+ value 'DESCRIPTION', value: 'description', description: 'Description.'
value 'TYPE', value: 'type', description: 'Type of the work item.'
value 'AUTHOR', value: 'author', description: 'Author name.'
value 'AUTHOR_USERNAME', value: 'author username', description: 'Author username.'
diff --git a/app/helpers/groups_helper.rb b/app/helpers/groups_helper.rb
index 66e710485af..0dfc832c457 100644
--- a/app/helpers/groups_helper.rb
+++ b/app/helpers/groups_helper.rb
@@ -132,16 +132,6 @@ module GroupsHelper
}
end
- def verification_for_group_creation_data
- # overridden in EE
- {}
- end
-
- def require_verification_for_namespace_creation_enabled?
- # overridden in EE
- false
- end
-
def group_name_and_path_app_data
{
base_path: root_url,
diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb
index cf6c251aa3f..a137ff4d6f2 100644
--- a/app/helpers/users_helper.rb
+++ b/app/helpers/users_helper.rb
@@ -191,7 +191,8 @@ module UsersHelper
followees: user.followees.count,
followers: user.followers.count,
user_calendar_path: user_calendar_path(user, :json),
- utc_offset: local_timezone_instance(user.timezone).now.utc_offset
+ utc_offset: local_timezone_instance(user.timezone).now.utc_offset,
+ user_id: user.id
}
end
diff --git a/app/serializers/deploy_keys/basic_deploy_key_entity.rb b/app/serializers/deploy_keys/basic_deploy_key_entity.rb
index 9184bc5f0ce..4a3dd3c8f08 100644
--- a/app/serializers/deploy_keys/basic_deploy_key_entity.rb
+++ b/app/serializers/deploy_keys/basic_deploy_key_entity.rb
@@ -10,6 +10,7 @@ module DeployKeys
expose :destroyed_when_orphaned?, as: :destroyed_when_orphaned
expose :almost_orphaned?, as: :almost_orphaned
expose :created_at
+ expose :expires_at
expose :updated_at
expose :can_edit
expose :user, as: :owner, using: ::API::Entities::UserBasic, if: -> (_, opts) { can_read_owner?(opts) }
diff --git a/app/serializers/group_deploy_key_entity.rb b/app/serializers/group_deploy_key_entity.rb
index c0bb0448a51..9e7be6de35d 100644
--- a/app/serializers/group_deploy_key_entity.rb
+++ b/app/serializers/group_deploy_key_entity.rb
@@ -7,6 +7,7 @@ class GroupDeployKeyEntity < Grape::Entity
expose :fingerprint
expose :fingerprint_sha256
expose :created_at
+ expose :expires_at
expose :updated_at
expose :group_deploy_keys_groups, using: GroupDeployKeysGroupEntity do |group_deploy_key|
group_deploy_key.group_deploy_keys_groups_for_user(options[:user])
diff --git a/app/services/branches/validate_new_service.rb b/app/services/branches/validate_new_service.rb
index e45183d160f..0bee7ffaa66 100644
--- a/app/services/branches/validate_new_service.rb
+++ b/app/services/branches/validate_new_service.rb
@@ -29,3 +29,5 @@ module Branches
end
end
end
+
+Branches::ValidateNewService.prepend_mod
diff --git a/app/views/groups/new.html.haml b/app/views/groups/new.html.haml
index 4c3ea0f292e..1d306d4d3b8 100644
--- a/app/views/groups/new.html.haml
+++ b/app/views/groups/new.html.haml
@@ -6,8 +6,9 @@
.group-edit-container
- .js-new-group-creation{ data: { has_errors: @group.errors.any?.to_s, root_path: root_path, groups_url: dashboard_groups_url }.merge(subgroup_creation_data(@group),
- verification_for_group_creation_data) }
+ .js-new-group-creation{ data: { has_errors: @group.errors.any?.to_s,
+ root_path: root_path,
+ groups_url: dashboard_groups_url }.merge(subgroup_creation_data(@group)) }
.row{ 'v-cloak': true }
#create-group-pane.tab-pane
diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml
index df7499f5f0f..9cb5ec39de2 100644
--- a/app/views/projects/_home_panel.html.haml
+++ b/app/views/projects/_home_panel.html.haml
@@ -1,7 +1,6 @@
- empty_repo = @project.empty_repo?
- show_auto_devops_callout = show_auto_devops_callout?(@project)
- emails_disabled = @project.emails_disabled?
-- cache_enabled = Feature.enabled?(:cache_home_panel, @project, type: :development)
.project-home-panel.js-show-on-project-root.gl-mt-4.gl-mb-5{ class: [("empty-project" if empty_repo)] }
.gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-sm-flex-direction-column.gl-mb-3.gl-gap-5
@@ -25,28 +24,26 @@
%span.gl-ml-3.gl-mb-3
= render 'shared/members/access_request_links', source: @project
- = cache_if(cache_enabled, [@project, @project.star_count, @project.forks_count, :buttons, current_user, @notification_setting], expires_in: 1.day) do
- .project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-center.gl-flex-wrap.gl-gap-3
- - if current_user
- - if current_user.admin?
- = link_to [:admin, @project], class: 'btn btn-default gl-button btn-icon', title: _('View project in admin area'),
- data: {toggle: 'tooltip', placement: 'top', container: 'body'} do
- = sprite_icon('admin')
- - if @notification_setting
- .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), project_id: @project.id, no_flip: 'true' } }
+ .project-repo-buttons.gl-display-flex.gl-justify-content-md-end.gl-align-items-center.gl-flex-wrap.gl-gap-3
+ - if current_user
+ - if current_user.admin?
+ = link_to [:admin, @project], class: 'btn btn-default gl-button btn-icon', title: _('View project in admin area'),
+ data: {toggle: 'tooltip', placement: 'top', container: 'body'} do
+ = sprite_icon('admin')
+ - if @notification_setting
+ .js-vue-notification-dropdown{ data: { disabled: emails_disabled.to_s, dropdown_items: notification_dropdown_items(@notification_setting).to_json, notification_level: @notification_setting.level, help_page_path: help_page_path('user/profile/notifications'), project_id: @project.id, no_flip: 'true' } }
- = render 'projects/buttons/star'
- = render 'projects/buttons/fork'
+ = render 'projects/buttons/star'
+ = render 'projects/buttons/fork'
- if can?(current_user, :read_code, @project)
- = cache_if(cache_enabled, [@project, :read_code], expires_in: 1.minute) do
- %nav.project-stats
- - if @project.empty_repo?
- = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors
- - else
- = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout)
+ %nav.project-stats
+ - if @project.empty_repo?
+ = render 'stat_anchor_list', anchors: @project.empty_repo_statistics_anchors
+ - else
+ = render 'stat_anchor_list', anchors: @project.statistics_anchors(show_auto_devops_callout: show_auto_devops_callout)
.gl-my-3
- = render "shared/projects/topics", project: @project, cache_enabled: cache_enabled
+ = render "shared/projects/topics", project: @project
.home-panel-home-desc.mt-1
- if @project.description.present?
.home-panel-description.text-break
diff --git a/app/views/shared/deploy_keys/_form.html.haml b/app/views/shared/deploy_keys/_form.html.haml
index 93f31629ca7..584d0758c76 100644
--- a/app/views/shared/deploy_keys/_form.html.haml
+++ b/app/views/shared/deploy_keys/_form.html.haml
@@ -27,6 +27,11 @@
.col-sm-10
= form.text_field :fingerprint, class: 'form-control gl-form-input', readonly: 'readonly'
+.form-group
+ .col-sm-10
+ = form.label :expires_at, _('Expiration date (optional)'), class: 'label-bold'
+ = form.text_field :expires_at, class: 'form-control gl-form-input', readonly: 'readonly'
+
- if deploy_keys_project.present?
= form.fields_for :deploy_keys_projects, deploy_keys_project do |deploy_keys_project_form|
.form-group
diff --git a/app/views/shared/deploy_keys/_project_group_form.html.haml b/app/views/shared/deploy_keys/_project_group_form.html.haml
index 11fa44fe282..c9e17b18264 100644
--- a/app/views/shared/deploy_keys/_project_group_form.html.haml
+++ b/app/views/shared/deploy_keys/_project_group_form.html.haml
@@ -15,6 +15,10 @@
.form-group.row
= deploy_keys_project_form.gitlab_ui_checkbox_component :can_push, _('Grant write permissions to this key'),
help_text: _('Allow this key to push to this repository')
+ .form-group.row
+ = f.label :expires_at, _('Expiration date (optional)'), class: 'label-bold'
+ = f.gitlab_ui_datepicker :expires_at, data: { qa_selector: 'deploy_key_expires_at_field' }, value: f.object.expires_at
+ %p.form-text.text-muted= ssh_key_expires_field_description
.form-group.row
= f.submit _("Add key"), data: { qa_selector: "add_deploy_key_button"}, pajamas_button: true
diff --git a/app/views/shared/projects/_topics.html.haml b/app/views/shared/projects/_topics.html.haml
index be513af4e3f..12246d1dcfa 100644
--- a/app/views/shared/projects/_topics.html.haml
+++ b/app/views/shared/projects/_topics.html.haml
@@ -1,31 +1,29 @@
-- cache_enabled = false unless local_assigns[:cache_enabled] == true
- max_project_topic_length = 15
- if project.topics.present?
- = cache_if(cache_enabled, [project, :topic_list], expires_in: 1.day) do
- .gl-w-full.gl-display-inline-flex.gl-flex-wrap.gl-font-base.gl-font-weight-normal.gl-align-items-center.gl-mx-n2.gl-my-n2{ 'data-testid': 'project_topic_list' }
- %span.gl-p-2.gl-text-gray-500
- = _('Topics') + ':'
- - project.topics_to_show.each do |topic|
- - explore_project_topic_path = topic_explore_projects_path(topic_name: topic[:name])
- - if topic[:title].length > max_project_topic_length
- %a.gl-p-2.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' }
- = gl_badge_tag truncate(topic[:title], length: max_project_topic_length)
- - else
- %a.gl-p-2{ href: explore_project_topic_path, itemprop: 'keywords' }
- = gl_badge_tag topic[:title]
+ .gl-w-full.gl-display-inline-flex.gl-flex-wrap.gl-font-base.gl-font-weight-normal.gl-align-items-center.gl-mx-n2.gl-my-n2{ 'data-testid': 'project_topic_list' }
+ %span.gl-p-2.gl-text-gray-500
+ = _('Topics') + ':'
+ - project.topics_to_show.each do |topic|
+ - explore_project_topic_path = topic_explore_projects_path(topic_name: topic[:name])
+ - if topic[:title].length > max_project_topic_length
+ %a.gl-p-2.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' }
+ = gl_badge_tag truncate(topic[:title], length: max_project_topic_length)
+ - else
+ %a.gl-p-2{ href: explore_project_topic_path, itemprop: 'keywords' }
+ = gl_badge_tag topic[:title]
- - if project.has_extra_topics?
- - title = _('More topics')
- - content = capture do
- %span.gl-display-inline-flex.gl-flex-wrap
- - project.topics_not_shown.each do |topic|
- - explore_project_topic_path = topic_explore_projects_path(topic_name: topic[:name])
- - if topic[:title].length > max_project_topic_length
- %a.gl-mr-3.gl-mb-3.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' }
- = gl_badge_tag truncate(topic[:title], length: max_project_topic_length)
- - else
- %a.gl-mr-3.gl-mb-3{ href: explore_project_topic_path, itemprop: 'keywords' }
- = gl_badge_tag topic[:title]
- .text-nowrap.gl-p-2{ role: 'button', tabindex: 0, data: { toggle: 'popover', triggers: 'focus hover', html: 'true', placement: 'top', title: title, content: content } }
- = _("+ %{count} more") % { count: project.count_of_extra_topics_not_shown }
+ - if project.has_extra_topics?
+ - title = _('More topics')
+ - content = capture do
+ %span.gl-display-inline-flex.gl-flex-wrap
+ - project.topics_not_shown.each do |topic|
+ - explore_project_topic_path = topic_explore_projects_path(topic_name: topic[:name])
+ - if topic[:title].length > max_project_topic_length
+ %a.gl-mr-3.gl-mb-3.has-tooltip{ data: { container: "body" }, title: topic[:title], href: explore_project_topic_path, itemprop: 'keywords' }
+ = gl_badge_tag truncate(topic[:title], length: max_project_topic_length)
+ - else
+ %a.gl-mr-3.gl-mb-3{ href: explore_project_topic_path, itemprop: 'keywords' }
+ = gl_badge_tag topic[:title]
+ .text-nowrap.gl-p-2{ role: 'button', tabindex: 0, data: { toggle: 'popover', triggers: 'focus hover', html: 'true', placement: 'top', title: title, content: content } }
+ = _("+ %{count} more") % { count: project.count_of_extra_topics_not_shown }
diff --git a/config/feature_flags/development/cache_home_panel.yml b/config/feature_flags/development/cache_home_panel.yml
deleted file mode 100644
index 63798cd31d0..00000000000
--- a/config/feature_flags/development/cache_home_panel.yml
+++ /dev/null
@@ -1,8 +0,0 @@
----
-name: cache_home_panel
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/57031
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/328421
-milestone: '13.12'
-type: development
-group: group::source code
-default_enabled: false
diff --git a/config/feature_flags/experiment/require_verification_for_namespace_creation.yml b/config/feature_flags/experiment/require_verification_for_namespace_creation.yml
deleted file mode 100644
index 5772d3217b8..00000000000
--- a/config/feature_flags/experiment/require_verification_for_namespace_creation.yml
+++ /dev/null
@@ -1,8 +0,0 @@
----
-name: require_verification_for_namespace_creation
-introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/77315
-rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/350251
-milestone: '14.8'
-type: experiment
-group: group::activation
-default_enabled: false
diff --git a/doc/api/deploy_keys.md b/doc/api/deploy_keys.md
index 5ec1fa6c040..7bf9fb0827a 100644
--- a/doc/api/deploy_keys.md
+++ b/doc/api/deploy_keys.md
@@ -43,6 +43,7 @@ Example response:
"fingerprint": "4a:9d:64:15:ed:3a:e6:07:6e:89:36:b3:3b:03:05:d9",
"fingerprint_sha256": "SHA256:Jrs3LD1Ji30xNLtTVf9NDCj7kkBgPBb2pjvTZ3HfIgU",
"created_at": "2013-10-02T10:12:29Z",
+ "expires_at": null,
"projects_with_write_access": [
{
"id": 73,
@@ -71,6 +72,7 @@ Example response:
"fingerprint": "0b:cf:58:40:b9:23:96:c7:ba:44:df:0e:9e:87:5e:75",
"fingerprint_sha256": "SHA256:lGI/Ys/Wx7PfMhUO1iuBH92JQKYN+3mhJZvWO4Q5ims",
"created_at": "2013-10-02T11:12:29Z",
+ "expires_at": null,
"projects_with_write_access": []
}
]
@@ -103,6 +105,7 @@ Example response:
"fingerprint": "4a:9d:64:15:ed:3a:e6:07:6e:89:36:b3:3b:03:05:d9",
"fingerprint_sha256": "SHA256:Jrs3LD1Ji30xNLtTVf9NDCj7kkBgPBb2pjvTZ3HfIgU",
"created_at": "2013-10-02T10:12:29Z",
+ "expires_at": null,
"can_push": false
},
{
@@ -112,6 +115,7 @@ Example response:
"fingerprint": "0b:cf:58:40:b9:23:96:c7:ba:44:df:0e:9e:87:5e:75",
"fingerprint_sha256": "SHA256:lGI/Ys/Wx7PfMhUO1iuBH92JQKYN+3mhJZvWO4Q5ims",
"created_at": "2013-10-02T11:12:29Z",
+ "expires_at": null,
"can_push": false
}
]
@@ -205,6 +209,7 @@ Example response:
"fingerprint": "4a:9d:64:15:ed:3a:e6:07:6e:89:36:b3:3b:03:05:d9",
"fingerprint_sha256": "SHA256:Jrs3LD1Ji30xNLtTVf9NDCj7kkBgPBb2pjvTZ3HfIgU",
"created_at": "2013-10-02T10:12:29Z",
+ "expires_at": null,
"can_push": false
}
```
@@ -220,12 +225,13 @@ project only if the original one is accessible by the same user.
POST /projects/:id/deploy_keys
```
-| Attribute | Type | Required | Description |
-| --------- | ---- | -------- | ----------- |
-| `id` | integer/string | yes | The ID or [URL-encoded path of the project](rest/index.md#namespaced-path-encoding) owned by the authenticated user |
-| `title` | string | yes | New deploy key's title |
-| `key` | string | yes | New deploy key |
-| `can_push` | boolean | no | Can deploy key push to the project's repository |
+| Attribute | Type | Required | Description |
+| ----------- | ---- | -------- | ----------- |
+| `id` | integer/string | yes | The ID or [URL-encoded path of the project](rest/index.md#namespaced-path-encoding) owned by the authenticated user |
+| `title` | string | yes | New deploy key's title |
+| `key` | string | yes | New deploy key |
+| `expires_at` | datetime | no | Expiration date for the deploy key. Does not expire if no value is provided. Expected in ISO 8601 format (`2019-03-15T08:00:00Z`) |
+| `can_push` | boolean | no | Can deploy key push to the project's repository |
```shell
curl --request POST --header "PRIVATE-TOKEN: <your_access_token>" --header "Content-Type: application/json" \
@@ -241,7 +247,8 @@ Example response:
"id" : 12,
"title" : "My deploy key",
"can_push": true,
- "created_at" : "2015-08-29T12:44:31.550Z"
+ "created_at" : "2015-08-29T12:44:31.550Z",
+ "expires_at": null
}
```
@@ -272,6 +279,7 @@ Example response:
"title": "New deploy key",
"key": "ssh-rsa AAAA...",
"created_at": "2015-08-29T12:44:31.550Z",
+ "expires_at": null,
"can_push": true
}
```
@@ -317,7 +325,8 @@ Example response:
"key" : "ssh-rsa AAAA...",
"id" : 12,
"title" : "My deploy key",
- "created_at" : "2015-08-29T12:44:31.550Z"
+ "created_at" : "2015-08-29T12:44:31.550Z",
+ "expires_at": null
}
```
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 61b7f623660..b85405823e6 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -22998,6 +22998,7 @@ Available fields to be exported as CSV.
| <a id="availableexportfieldsauthor"></a>`AUTHOR` | Author name. |
| <a id="availableexportfieldsauthor_username"></a>`AUTHOR_USERNAME` | Author username. |
| <a id="availableexportfieldscreated_at"></a>`CREATED_AT` | Date of creation. |
+| <a id="availableexportfieldsdescription"></a>`DESCRIPTION` | Description. |
| <a id="availableexportfieldsid"></a>`ID` | Unique identifier. |
| <a id="availableexportfieldstitle"></a>`TITLE` | Title. |
| <a id="availableexportfieldstype"></a>`TYPE` | Type of the work item. |
diff --git a/doc/ci/cloud_services/index.md b/doc/ci/cloud_services/index.md
index 115488c3f73..d2d609196e4 100644
--- a/doc/ci/cloud_services/index.md
+++ b/doc/ci/cloud_services/index.md
@@ -54,61 +54,7 @@ as a starting point, and for more information about supply chain attacks, see
## How it works
-Each job can be configured with ID tokens, which are provided as a CI/CD variable. These JWTs can be used to authenticate with the OIDC-supported cloud provider such as AWS, Azure, GCP, or Vault.
-
-The following fields are included in the JWT:
-
-| Field | When | Description |
-| ----------------------- | ------ | ----------- |
-| `aud` | Always | Specified in the [ID tokens](../yaml/index.md#id_tokens) configuration |
-| `jti` | Always | Unique identifier for this token |
-| `iss` | Always | Issuer, the domain of your GitLab instance |
-| `iat` | Always | Issued at |
-| `nbf` | Always | Not valid before |
-| `exp` | Always | Expires at |
-| `sub` | Always |`project_path:{group}/{project}:ref_type:{type}:ref:{branch_name}` |
-| `namespace_id` | Always | Use this to scope to group or user level namespace by ID |
-| `namespace_path` | Always | Use this to scope to group or user level namespace by path |
-| `project_id` | Always | Use this to scope to project by ID |
-| `project_path` | Always | Use this to scope to project by path |
-| `user_id` | Always | ID of the user executing the job |
-| `user_login` | Always | Username of the user executing the job |
-| `user_email` | Always | Email of the user executing the job |
-| `pipeline_id` | Always | ID of this pipeline |
-| `pipeline_source` | Always | [Pipeline source](../jobs/job_control.md#common-if-clauses-for-rules) |
-| `job_id` | Always | ID of this job |
-| `ref` | Always | Git ref for this job |
-| `ref_type` | Always | Git ref type, either `branch` or `tag` |
-| `ref_protected` | Always | `true` if this Git ref is protected, `false` otherwise |
-| `environment` | Job is creating a deployment | Environment this job deploys to ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/294440) in GitLab 13.9) |
-| `environment_protected` | Job is creating a deployment |`true` if deployed environment is protected, `false` otherwise ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/294440) in GitLab 13.9) |
-
-```json
-{
- "jti": "c82eeb0c-5c6f-4a33-abf5-4c474b92b558",
- "iss": "https://gitlab.example.com",
- "aud": "https://vault.example.com",
- "iat": 1585710286,
- "nbf": 1585798372,
- "exp": 1585713886,
- "sub": "project_path:mygroup/myproject:ref_type:branch:ref:main",
- "namespace_id": "1",
- "namespace_path": "mygroup",
- "project_id": "22",
- "project_path": "mygroup/myproject",
- "user_id": "42",
- "user_login": "myuser",
- "user_email": "myuser@example.com",
- "pipeline_id": "1212",
- "pipeline_source": "web",
- "job_id": "1212",
- "ref": "auto-deploy-2020-04-01",
- "ref_type": "branch",
- "ref_protected": "true",
- "environment": "production",
- "environment_protected": "true"
-}
-```
+Each job can be configured with ID tokens, which are provided as a CI/CD variable containing the [token payload](../secrets/id_token_authentication.md#token-payload). These JWTs can be used to authenticate with the OIDC-supported cloud provider such as AWS, Azure, GCP, or Vault.
### Authorization workflow
diff --git a/doc/ci/secrets/id_token_authentication.md b/doc/ci/secrets/id_token_authentication.md
index b10763b40d6..d6d1f62e47d 100644
--- a/doc/ci/secrets/id_token_authentication.md
+++ b/doc/ci/secrets/id_token_authentication.md
@@ -35,60 +35,64 @@ services with which a token can authenticate. This reduces the severity of havin
### Token payload
-The following fields are included in each ID token:
+The following standard claims are included in each ID token:
+
+| Field | Description |
+|--------------------------------------------------------------------|-------------|
+| [`iss`](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.1) | Issuer of the token, which is the domain of the GitLab instance ("issuer" claim). |
+| [`sub`](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.2) | `project_path:{group}/{project}:ref_type:{type}:ref:{branch_name}` ("subject" claim). |
+| [`aud`](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.3) | Intended audience for the token ("audience" claim). Specified in the [ID tokens](../yaml/index.md#id_tokens) configuration. The domain of the GitLab instance by default. |
+| [`exp`](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.4) | The expiration time ("expiration time" claim). |
+| [`nbf`](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.5) | The time after which the token becomes valid ("not before" claim). |
+| [`iat`](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.6) | The time the JWT was issued ("issued at" claim). |
+| [`jti`](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.7) | Unique identifier for the token ("JWT ID" claim). |
+
+The token also includes custom claims provided by GitLab:
| Field | When | Description |
|-------------------------|------------------------------|-------------|
-| [`aud`](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.3) | Always | Intended audience for the token ("audience" claim). Configured in GitLab the CI/CD configuration. The domain of the GitLab instance by default. |
-| [`exp`](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.4) | Always | The expiration time ("expiration time" claim). |
-| [`iat`](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.6) | Always | The time the JWT was issued ("issued at" claim). |
-| [`iss`](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.1) | Always | Issuer of the token, which is the domain of the GitLab instance ("issuer" claim). |
-| [`jti`](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.7) | Always | Unique identifier for the token ("JWT ID" claim). |
-| [`nbf`](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.5) | Always | The time after which the token becomes valid ("not before" claim). |
-| [`sub`](https://www.rfc-editor.org/rfc/rfc7519.html#section-4.1.2) | Always | `project_path:{group}/{project}:ref_type:{type}:ref:{branch_name}` ("subject" claim). |
-| `deployment_tier` | Job specifies an environment | [Deployment tier](../environments/index.md#deployment-tier-of-environments) of the environment the job specifies. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/363590) in GitLab 15.2. |
-| `environment_protected` | Job specifies an environment | `true` if specified environment is protected, `false` otherwise. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/294440) in GitLab 13.9. |
-| `environment` | Job specifies an environment | Environment the job specifies. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/294440) in GitLab 13.9. |
-| `job_id` | Always | ID of the job. |
-| `namespace_id` | Always | Use to scope to group or user level namespace by ID. |
-| `namespace_path` | Always | Use to scope to group or user level namespace by path. |
+| `namespace_id` | Always | Use this to scope to group or user level namespace by ID. |
+| `namespace_path` | Always | Use this to scope to group or user level namespace by path. |
+| `project_id` | Always | Use this to scope to project by ID. |
+| `project_path` | Always | Use this to scope to project by path. |
+| `user_id` | Always | ID of the user executing the job. |
+| `user_login` | Always | Username of the user executing the job. |
+| `user_email` | Always | Email of the user executing the job. |
| `pipeline_id` | Always | ID of the pipeline. |
| `pipeline_source` | Always | [Pipeline source](../jobs/job_control.md#common-if-clauses-for-rules). |
-| `project_id` | Always | Use to scope to project by ID. |
-| `project_path` | Always | Use to scope to project by path. |
-| `ref_protected` | Always | `true` if the Git ref is protected, `false` otherwise. |
-| `ref_type` | Always | Git ref type, either `branch` or `tag`. |
+| `job_id` | Always | ID of the job. |
| `ref` | Always | Git ref for the job. |
-| `user_email` | Always | Email of the user executing the job. |
-| `user_id` | Always | ID of the user executing the job. |
-| `user_login` | Always | Username of the user executing the job. |
-
-Example ID token payload:
+| `ref_type` | Always | Git ref type, either `branch` or `tag`. |
+| `ref_protected` | Always | `true` if the Git ref is protected, `false` otherwise. |
+| `environment` | Job specifies an environment | Environment this job deploys to ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/294440) in GitLab 13.9). |
+| `environment_protected` | Job specifies an environment | `true` if deployed environment is protected, `false` otherwise ([introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/294440) in GitLab 13.9). |
+| `deployment_tier` | Job specifies an environment | [Deployment tier](../environments/index.md#deployment-tier-of-environments) of the environment the job specifies. [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/363590) in GitLab 15.2. |
```json
{
- "jti": "c82eeb0c-5c6f-4a33-abf5-4c474b92b558",
- "aud": "hashicorp.example.com",
- "iss": "gitlab.example.com",
- "iat": 1585710286,
- "nbf": 1585798372,
- "exp": 1585713886,
- "sub": "job_1212",
- "namespace_id": "1",
- "namespace_path": "mygroup",
- "project_id": "22",
- "project_path": "mygroup/myproject",
- "user_id": "42",
- "user_login": "myuser",
- "user_email": "myuser@example.com",
- "pipeline_id": "1212",
- "pipeline_source": "web",
- "job_id": "1212",
- "ref": "auto-deploy-2020-04-01",
+ "namespace_id": "72",
+ "namespace_path": "my-group",
+ "project_id": "20",
+ "project_path": "my-group/my-project",
+ "user_id": "1",
+ "user_login": "sample-user",
+ "user_email": "sample-user@example.com",
+ "pipeline_id": "574",
+ "pipeline_source": "push",
+ "job_id": "302",
+ "ref": "feature-branch-1",
"ref_type": "branch",
- "ref_protected": "true",
- "environment": "production",
- "environment_protected": "true"
+ "ref_protected": "false",
+ "environment": "test-environment2",
+ "environment_protected": "false",
+ "deployment_tier": "testing",
+ "jti": "235b3a54-b797-45c7-ae9a-f72d7bc6ef5b",
+ "iss": "https://gitlab.example.com",
+ "iat": 1681395193,
+ "nbf": 1681395188,
+ "exp": 1681398793,
+ "sub": "project_path:my-group/my-project:ref_type:branch:ref:feature-branch-1",
+ "aud": "https://vault.example.com"
}
```
diff --git a/doc/user/admin_area/review_abuse_reports.md b/doc/user/admin_area/review_abuse_reports.md
index b8531fded18..314e0c77f36 100644
--- a/doc/user/admin_area/review_abuse_reports.md
+++ b/doc/user/admin_area/review_abuse_reports.md
@@ -1,6 +1,6 @@
---
-stage: Manage
-group: Authentication and Authorization
+stage: Anti-Abuse
+group: Anti-Abuse
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
type: reference, howto
---
diff --git a/doc/user/application_security/policies/index.md b/doc/user/application_security/policies/index.md
index 6f6b4390b7d..05996a70d3d 100644
--- a/doc/user/application_security/policies/index.md
+++ b/doc/user/application_security/policies/index.md
@@ -38,10 +38,6 @@ project and the security policy project, this is not recommended. Keeping the se
project separate from the development project allows for complete separation of duties between
security/compliance teams and development teams.
-You should not link a security policy project to a development project and to the group
-or sub-group the development project belongs to at the same time. Linking this way will result in
-approval rules from the Scan Result Policy not being applied to merge requests in the development project.
-
All security policies are stored in the `.gitlab/security-policies/policy.yml` YAML file inside the
linked security policy project. The format for this YAML is specific to the type of policy that is
stored there. Examples and schema information are available for the following policy types:
diff --git a/doc/user/clusters/agent/ci_cd_workflow.md b/doc/user/clusters/agent/ci_cd_workflow.md
index 60c36b35b53..1ca558adbc2 100644
--- a/doc/user/clusters/agent/ci_cd_workflow.md
+++ b/doc/user/clusters/agent/ci_cd_workflow.md
@@ -185,6 +185,17 @@ deploy:
# ... rest of your job configuration
```
+### Environments with KAS that use self-signed certificates
+
+If you use an environment with KAS and a self-signed certificate, you must configure your Kubernetes client to trust the certificate authority (CA) that signed your certificate.
+
+To configure your client, do one of the following:
+
+- Set a CI/CD variable `SSL_CERT_FILE` with the KAS certificate in PEM format.
+- Configure the Kubernetes client with `--certificate-authority=$KAS_CERTIFICATE`, where `KAS_CERTIFICATE` is a CI/CD variable with the CA certificate of KAS.
+- Place the certificates in an appropriate location in the job container by updating the container image or mounting via the runner.
+- Not recommended. Configure the Kubernetes client with `--insecure-skip-tls-verify=true`.
+
## Restrict project and group access by using impersonation **(PREMIUM)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/345014) in GitLab 14.5.
@@ -342,3 +353,16 @@ If you attempt to use `kubectl` without TLS, you might get an error like:
$ kubectl get pods
error: You must be logged in to the server (the server has asked for the client to provide credentials)
```
+
+### Unable to connect to the server: certificate signed by unknown authority
+
+If you use an environment with KAS and a self-signed certificate, your `kubectl` call might return this error:
+
+```plaintext
+kubectl get pods
+Unable to connect to the server: x509: certificate signed by unknown authority
+```
+
+The error occurs because the job does not trust the certificate authority (CA) that signed the KAS certificate.
+
+To resolve the issue, [configure `kubectl` to trust the CA](#environments-with-kas-that-use-self-signed-certificates).
diff --git a/doc/user/permissions.md b/doc/user/permissions.md
index 4ca10caef86..f4f205f8499 100644
--- a/doc/user/permissions.md
+++ b/doc/user/permissions.md
@@ -540,3 +540,4 @@ the Owner role:
- Additional permissions can only be applied to users with the Guest role.
- If a user with a custom role is shared with a group or project, their custom role is not transferred over with them. The user has the regular Guest role in the new group or project.
+- You cannot use an [Auditor user](../administration/auditor_users.md) as a template for a custom role.
diff --git a/doc/user/project/codeowners/index.md b/doc/user/project/codeowners/index.md
index cbf6cc2c186..bbb39179975 100644
--- a/doc/user/project/codeowners/index.md
+++ b/doc/user/project/codeowners/index.md
@@ -58,7 +58,7 @@ GitLab shows the Code Owners at the top of the page.
## Set up Code Owners
1. Create a `CODEOWNERS` file in your [preferred location](#code-owners-file).
-1. Define some rules in the file following the [Code Owners syntax reference](#code-owners-syntax-reference).
+1. Define some rules in the file following the [Code Owners syntax reference](reference.md).
Some suggestions:
- Configure [All eligible approvers](../merge_requests/approvals/rules.md#code-owners-as-eligible-approvers) approval rule.
- [Require Code Owner approval](../protected_branches.md#require-code-owner-approval-on-a-protected-branch) on a protected branch.
@@ -337,371 +337,15 @@ The `Documentation` Code Owners section under the **Approval Rules** area displa
The Code Owner approval and protected branch features do not apply to users who
are **Allowed to push**.
-## Error handling in Code Owners
-
-### Entries with spaces
-
-Paths containing whitespace must be escaped with backslashes: `path\ with\ spaces/*.md`.
-Without the backslashes, the path after the first whitespace is parsed as an owner.
-GitLab the parses `folder with spaces/*.md @group` into
-`path: "folder", owners: " with spaces/*.md @group"`.
-
-### Unparsable sections
-
-If a section heading cannot be parsed, the section is:
-
-1. Parsed as an entry.
-1. Added to the previous section.
-1. If no previous section exists, the section is added to the default section.
-
-For example, this file is missing a square closing bracket:
-
-```plaintext
-* @group
-
-[Section name
-docs/ @docs_group
-```
-
-GitLab recognizes the heading `[Section name` as an entry. The default section includes 3 rules:
-
-- Default section
- - `*` owned by `@group`
- - `[Section` owned by `name`
- - `docs/` owned by `@docs_group`
-
-This file contains an unescaped space between the words `Section` and `name`.
-GitLab recognizes the intended heading as an entry:
-
-```plaintext
-[Docs]
-docs/**/* @group
-
-[Section name]{2} @group
-docs/ @docs_group
-```
-
-The `[Docs]` section then includes 3 rules:
-
-- `docs/**/*` owned by `@group`
-- `[Section` owned by `name]{2} @group`
-- `docs/` owned by `@docs_group`
-
-### Malformed owners
-
-Each entry must contain 1 or more owners to be valid, malformed owners are ignored.
-For example `/path/* @group user_without_at_symbol @user_with_at_symbol`
-is owned by `@group` and `@user_with_at_symbol`.
-
-### Inaccessible or incorrect owners
-
-Inaccessible or incorrect owners are ignored. For example, if `@group`, `@username`,
-and `example@gitlab.com` are accessible on the project and we create an entry:
-
-```plaintext
-* @group @grou @username @i_left @i_dont_exist example@gitlab.com invalid@gitlab.com
-```
-
-GitLab ignores `@grou`, `@i_left`, `@i_dont_exist`, and `invalid@gitlab.com`.
-
-For more information on who is accessible, see [Groups as Code Owners](#groups-as-code-owners).
-
-### Zero owners
-
-If an entry includes no owners, or zero [accessible owners](#inaccessible-or-incorrect-owners)
-exist, the entry is invalid. Because this rule can never be satisfied, GitLab
-auto-approves it in merge requests.
-
-NOTE:
-When a protected branch has `Require code owner approval` enabled, rules with
-zero owners are still honored.
-
-### Less than 1 required approval
-
-When [defining the number of approvals](#require-multiple-approvals-from-code-owners) for a section,
-the minimum number of approvals is `1`. Setting the number of approvals to
-`0` results in GitLab requiring one approval.
-
-## Example `CODEOWNERS` file
-
-```plaintext
-# This is an example of a CODEOWNERS file.
-# Lines that start with `#` are ignored.
-
-# app/ @commented-rule
-
-# Specify a default Code Owner by using a wildcard:
-* @default-codeowner
-
-# Specify multiple Code Owners by using a tab or space:
-* @multiple @code @owners
-
-# Rules defined later in the file take precedence over the rules
-# defined before.
-# For example, for all files with a filename ending in `.rb`:
-*.rb @ruby-owner
-
-# Files with a `#` can still be accessed by escaping the pound sign:
-\#file_with_pound.rb @owner-file-with-pound
-
-# Specify multiple Code Owners separated by spaces or tabs.
-# In the following case the CODEOWNERS file from the root of the repo
-# has 3 Code Owners (@multiple @code @owners):
-CODEOWNERS @multiple @code @owners
-
-# You can use both usernames or email addresses to match
-# users. Everything else is ignored. For example, this code
-# specifies the `@legal` and a user with email `janedoe@gitlab.com` as the
-# owner for the LICENSE file:
-LICENSE @legal this_does_not_match janedoe@gitlab.com
-
-# Use group names to match groups, and nested groups to specify
-# them as owners for a file:
-README @group @group/with-nested/subgroup
-
-# End a path in a `/` to specify the Code Owners for every file
-# nested in that directory, on any level:
-/docs/ @all-docs
-
-# End a path in `/*` to specify Code Owners for every file in
-# a directory, but not nested deeper. This code matches
-# `docs/index.md` but not `docs/projects/index.md`:
-/docs/* @root-docs
-
-# Include `/**` to specify Code Owners for all subdirectories
-# in a directory. This rule matches `docs/projects/index.md` or
-# `docs/development/index.md`
-/docs/**/*.md @root-docs
-
-# This code makes matches a `lib` directory nested anywhere in the repository:
-lib/ @lib-owner
-
-# This code match only a `config` directory in the root of the repository:
-/config/ @config-owner
-
-# If the path contains spaces, escape them like this:
-path\ with\ spaces/ @space-owner
-
-# Code Owners section:
-[Documentation]
-ee/docs @docs
-docs @docs
-
-# Use of default owners for a section. In this case, all files (*) are owned by
-the dev team except the README.md and data-models which are owned by other teams.
-[Development] @dev-team
-*
-README.md @docs-team
-data-models/ @data-science-team
-
-# This section is combined with the previously defined [Documentation] section:
-[DOCUMENTATION]
-README.md @docs
-```
-
-## Code Owners syntax reference
-
-### Comments
-
-Lines beginning with `#` are ignored:
-
-```codeowners
-# This is a comment
-```
-
-### Sections
-
-Sections are groups of entries. A section begins with a section heading in square brackets, followed by the entries.
-
-```codeowners
-[Section name]
-/path/of/protected/file.rb @username
-/path/of/protected/dir/ @group
-```
-
-### Section headings
-
-Section headings must always have a name. They can also be made optional, or require a number of approvals. A list of default owners can be added to the section heading line.
-
-```codeowners
-# Required section
-[Section name]
-
-# Optional section
-^[Section name]
-
-# Section requiring 5 approvals
-[Section name][5]
-
-# Section with @username as default owner
-[Section name] @username
-
-# Section with @group and @subgroup as default owners and requiring 2 approvals
-[Section name][2] @group @subgroup
-```
-
-### Section names
-
-Sections names are defined between square brackets.
-Section names are not case-sensitive. [Sections with duplicate names are combined](#sections-with-duplicate-names).
-
-```codeowners
-[Section name]
-```
-
-### Required sections
-
-Required sections do not include `^` before the [section name](#section-names).
-
-```codeowners
-[Required section]
-```
-
-### Optional sections
-
-Optional sections include a `^` before the [section name](#section-names).
-
-```codeowners
-^[Optional section]
-```
-
-### Sections requiring multiple approvals
-
-Sections requiring multiple approvals include the number of approvals in square brackets after the [section name](#section-names).
-
-```codeowners
-[Section requiring 5 approvals][5]
-```
-
-NOTE:
-Optional sections ignore the number of approvals required.
-
-### Sections with default owners
-
-You can define a default owner for the entries in a section by appending the owners to the [section heading](#section-headings).
-
-```codeowners
-# Section with @username as default owner
-[Section name] @username
-
-# Section with @group and @subgroup as default owners and requiring 2 approvals
-[Section name][2] @group @subgroup
-```
-
-### Code Owner entries
-
-Each Code Owner entry includes a path followed by one or more owners.
-
-```codeowners
-README.md @username1
-```
-
-NOTE:
-If an entry is duplicated in a section, [the last entry is used from each section.](#define-more-specific-owners-for-more-specifically-defined-files-or-directories)
-
-### Relative paths
-
-If a path does not start with a `/`, the path is treated as if it starts with a [globstar](#globstar-paths).
-`README.md` is treated the same way as `/**/README.md`
-
-```codeowners
-# This will match /README.md, /internal/README.md, /app/lib/README.md
-README.md @username
-
-# This will match /internal/README.md, /docs/internal/README.md, /docs/api/internal/README.md
-internal/README.md
-```
-
-### Absolute paths
-
-If a path starts with a `/` it matches the root of the repository.
-
-```codeowners
-# Matches only the file named `README.md` in the root of the repository.
-/README.md
-
-# Matches only the file named `README.md` inside the `/docs` directory.
-/docs/README.md
-```
-
-### Directory paths
-
-If a path ends with `/`, the path matches any file in the directory.
-
-```codeowners
-# This is the same as `/docs/**/*`
-/docs/
-```
-
-### Wildcard paths
-
-Wildcards can be used to match one of more characters of a path.
-
-```codeowners
-# Any markdown files in the docs directory
-/docs/*.md @username
-
-# /docs/index file of any filetype
-# For example: /docs/index.md, /docs/index.html, /docs/index.xml
-/docs/index.* @username
-
-# Any file in the docs directory with 'spec' in the name.
-# For example: /docs/qa_specs.rb, /docs/spec_helpers.rb, /docs/runtime.spec
-/docs/*spec* @username
-
-# README.md files one level deep within the docs directory
-# For example: /docs/api/README.md
-/docs/*/README.md @username
-```
-
-### Globstar paths
-
-Globstars (`**`) can be used to match zero or more directories and subdirectories.
-
-```codeowners
-# This will match /docs/index.md, /docs/api/index.md, /docs/api/graphql/index.md
-/docs/**/index.md
-```
-
-### Entry owners
-
-Entries must be followed by one or more owner, these can be groups, subgroups,
-and users. Order of owners is not important.
-
-```codeowners
-/path/to/entry.rb @group
-/path/to/entry.rb @group/subgroup
-/path/to/entry.rb @user
-/path/to/entry.rb @group @group/subgroup @user
-```
-
-### Groups as entry owners
-
-Groups and subgroups can be owners of an entry.
-Each entry can be owned by [one or more owners](#entry-owners).
-For more details see the [Groups as Code Owners section](#groups-as-code-owners).
-
-```codeowners
-/path/to/entry.rb @group
-/path/to/entry.rb @group/subgroup
-/path/to/entry.rb @group @group/subgroup
-```
-
-### Users as entry owners
-
-Users can be owners of an entry. Each entry can be owned by [one or more owners](#entry-owners).
-
-```codeowners
-/path/to/entry.rb @username1
-/path/to/entry.rb @username1 @username2
-```
-
## Technical Resources
[Code Owners development guidelines](../../../development/code_owners/index.md)
## Troubleshooting
+For more information about how the Code Owners feature handles errors, see the
+[Code Owners reference](reference.md).
+
### Approvals shown as optional
A Code Owner approval rule is optional if any of these conditions are true:
diff --git a/doc/user/project/codeowners/reference.md b/doc/user/project/codeowners/reference.md
new file mode 100644
index 00000000000..dbb39fafabe
--- /dev/null
+++ b/doc/user/project/codeowners/reference.md
@@ -0,0 +1,371 @@
+---
+stage: Create
+group: Source Code
+info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/product/ux/technical-writing/#assignments
+---
+
+# Code Owners syntax and error handling **(PREMIUM)**
+
+This page describes the syntax and error handling used in Code Owners files,
+and provides an example file.
+
+## Code Owners syntax
+
+### Comments
+
+Lines beginning with `#` are ignored:
+
+```plaintext
+# This is a comment
+```
+
+### Sections
+
+Sections are groups of entries. A section begins with a section heading in square brackets, followed by the entries.
+
+```plaintext
+[Section name]
+/path/of/protected/file.rb @username
+/path/of/protected/dir/ @group
+```
+
+#### Section headings
+
+Section headings must always have a name. They can also be made optional, or
+require a number of approvals. A list of default owners can be added to the section heading line.
+
+```plaintext
+# Required section
+[Section name]
+
+# Optional section
+^[Section name]
+
+# Section requiring 5 approvals
+[Section name][5]
+
+# Section with @username as default owner
+[Section name] @username
+
+# Section with @group and @subgroup as default owners and requiring 2 approvals
+[Section name][2] @group @subgroup
+```
+
+#### Section names
+
+Sections names are defined between square brackets. Section names are not case-sensitive.
+[Sections with duplicate names](index.md#sections-with-duplicate-names) are combined.
+
+```plaintext
+[Section name]
+```
+
+#### Required sections
+
+Required sections do not include `^` before the [section name](#section-names).
+
+```plaintext
+[Required section]
+```
+
+#### Optional sections
+
+Optional sections include a `^` before the [section name](#section-names).
+
+```plaintext
+^[Optional section]
+```
+
+#### Sections requiring multiple approvals
+
+Sections requiring multiple approvals include the number of approvals in square brackets after the [section name](#section-names).
+
+```plaintext
+[Section requiring 5 approvals][5]
+```
+
+NOTE:
+Optional sections ignore the number of approvals required.
+
+#### Sections with default owners
+
+You can define a default owner for the entries in a section by appending the owners to the [section heading](#section-headings).
+
+```plaintext
+# Section with @username as default owner
+[Section name] @username
+
+# Section with @group and @subgroup as default owners and requiring 2 approvals
+[Section name][2] @group @subgroup
+```
+
+### Code Owner entries
+
+Each Code Owner entry includes a path followed by one or more owners.
+
+```plaintext
+README.md @username1
+```
+
+NOTE:
+If an entry is duplicated in a section, [the last entry is used from each section.](index.md#define-more-specific-owners-for-more-specifically-defined-files-or-directories)
+
+### Relative paths
+
+If a path does not start with a `/`, the path is treated as if it starts with
+a [globstar](#globstar-paths). `README.md` is treated the same way as `/**/README.md`:
+
+```plaintext
+# This will match /README.md, /internal/README.md, /app/lib/README.md
+README.md @username
+
+# This will match /internal/README.md, /docs/internal/README.md, /docs/api/internal/README.md
+internal/README.md
+```
+
+### Absolute paths
+
+If a path starts with a `/` it matches the root of the repository.
+
+```plaintext
+# Matches only the file named `README.md` in the root of the repository.
+/README.md
+
+# Matches only the file named `README.md` inside the `/docs` directory.
+/docs/README.md
+```
+
+### Directory paths
+
+If a path ends with `/`, the path matches any file in the directory.
+
+```plaintext
+# This is the same as `/docs/**/*`
+/docs/
+```
+
+### Wildcard paths
+
+Wildcards can be used to match one of more characters of a path.
+
+```plaintext
+# Any markdown files in the docs directory
+/docs/*.md @username
+
+# /docs/index file of any filetype
+# For example: /docs/index.md, /docs/index.html, /docs/index.xml
+/docs/index.* @username
+
+# Any file in the docs directory with 'spec' in the name.
+# For example: /docs/qa_specs.rb, /docs/spec_helpers.rb, /docs/runtime.spec
+/docs/*spec* @username
+
+# README.md files one level deep within the docs directory
+# For example: /docs/api/README.md
+/docs/*/README.md @username
+```
+
+### Globstar paths
+
+Globstars (`**`) can be used to match zero or more directories and subdirectories.
+
+```plaintext
+# This will match /docs/index.md, /docs/api/index.md, /docs/api/graphql/index.md
+/docs/**/index.md
+```
+
+### Entry owners
+
+Entries must be followed by one or more owner. These can be groups, subgroups,
+and users. Order of owners is not important.
+
+```plaintext
+/path/to/entry.rb @group
+/path/to/entry.rb @group/subgroup
+/path/to/entry.rb @user
+/path/to/entry.rb @group @group/subgroup @user
+```
+
+#### Groups as entry owners
+
+Groups and subgroups can be owners of an entry.
+Each entry can be owned by [one or more owners](#entry-owners).
+For more details see the [Groups as Code Owners section](index.md#groups-as-code-owners).
+
+```plaintext
+/path/to/entry.rb @group
+/path/to/entry.rb @group/subgroup
+/path/to/entry.rb @group @group/subgroup
+```
+
+### Users as entry owners
+
+Users can be owners of an entry. Each entry can be owned by
+[one or more owners](#entry-owners).
+
+```plaintext
+/path/to/entry.rb @username1
+/path/to/entry.rb @username1 @username2
+```
+
+## Error handling in Code Owners
+
+### Entries with spaces
+
+Paths containing whitespace must be escaped with backslashes: `path\ with\ spaces/*.md`.
+Without the backslashes, the path after the first whitespace is parsed as an owner.
+GitLab the parses `folder with spaces/*.md @group` into
+`path: "folder", owners: " with spaces/*.md @group"`.
+
+### Unparsable sections
+
+If a section heading cannot be parsed, the section is:
+
+1. Parsed as an entry.
+1. Added to the previous section.
+1. If no previous section exists, the section is added to the default section.
+
+For example, this file is missing a square closing bracket:
+
+```plaintext
+* @group
+
+[Section name
+docs/ @docs_group
+```
+
+GitLab recognizes the heading `[Section name` as an entry. The default section includes 3 rules:
+
+- Default section
+ - `*` owned by `@group`
+ - `[Section` owned by `name`
+ - `docs/` owned by `@docs_group`
+
+This file contains an unescaped space between the words `Section` and `name`.
+GitLab recognizes the intended heading as an entry:
+
+```plaintext
+[Docs]
+docs/**/* @group
+
+[Section name]{2} @group
+docs/ @docs_group
+```
+
+The `[Docs]` section then includes 3 rules:
+
+- `docs/**/*` owned by `@group`
+- `[Section` owned by `name]{2} @group`
+- `docs/` owned by `@docs_group`
+
+### Malformed owners
+
+Each entry must contain 1 or more owners to be valid, malformed owners are ignored.
+For example `/path/* @group user_without_at_symbol @user_with_at_symbol`
+is owned by `@group` and `@user_with_at_symbol`.
+
+### Inaccessible or incorrect owners
+
+Inaccessible or incorrect owners are ignored. For example, if `@group`, `@username`,
+and `example@gitlab.com` are accessible on the project and we create an entry:
+
+```plaintext
+* @group @grou @username @i_left @i_dont_exist example@gitlab.com invalid@gitlab.com
+```
+
+GitLab ignores `@grou`, `@i_left`, `@i_dont_exist`, and `invalid@gitlab.com`.
+
+For more information on who is accessible, see [Groups as Code Owners](index.md#groups-as-code-owners).
+
+### Zero owners
+
+If an entry includes no owners, or zero [accessible owners](#inaccessible-or-incorrect-owners)
+exist, the entry is invalid. Because this rule can never be satisfied, GitLab
+auto-approves it in merge requests.
+
+NOTE:
+When a protected branch has `Require code owner approval` enabled, rules with
+zero owners are still honored.
+
+### Less than 1 required approval
+
+When [defining the number of approvals](index.md#require-multiple-approvals-from-code-owners) for a section,
+the minimum number of approvals is `1`. Setting the number of approvals to
+`0` results in GitLab requiring one approval.
+
+## Example `CODEOWNERS` file
+
+```plaintext
+# This is an example of a CODEOWNERS file.
+# Lines that start with `#` are ignored.
+
+# app/ @commented-rule
+
+# Specify a default Code Owner by using a wildcard:
+* @default-codeowner
+
+# Specify multiple Code Owners by using a tab or space:
+* @multiple @code @owners
+
+# Rules defined later in the file take precedence over the rules
+# defined before.
+# For example, for all files with a filename ending in `.rb`:
+*.rb @ruby-owner
+
+# Files with a `#` can still be accessed by escaping the pound sign:
+\#file_with_pound.rb @owner-file-with-pound
+
+# Specify multiple Code Owners separated by spaces or tabs.
+# In the following case the CODEOWNERS file from the root of the repo
+# has 3 Code Owners (@multiple @code @owners):
+CODEOWNERS @multiple @code @owners
+
+# You can use both usernames or email addresses to match
+# users. Everything else is ignored. For example, this code
+# specifies the `@legal` and a user with email `janedoe@gitlab.com` as the
+# owner for the LICENSE file:
+LICENSE @legal this_does_not_match janedoe@gitlab.com
+
+# Use group names to match groups, and nested groups to specify
+# them as owners for a file:
+README @group @group/with-nested/subgroup
+
+# End a path in a `/` to specify the Code Owners for every file
+# nested in that directory, on any level:
+/docs/ @all-docs
+
+# End a path in `/*` to specify Code Owners for every file in
+# a directory, but not nested deeper. This code matches
+# `docs/index.md` but not `docs/projects/index.md`:
+/docs/* @root-docs
+
+# Include `/**` to specify Code Owners for all subdirectories
+# in a directory. This rule matches `docs/projects/index.md` or
+# `docs/development/index.md`
+/docs/**/*.md @root-docs
+
+# This code makes matches a `lib` directory nested anywhere in the repository:
+lib/ @lib-owner
+
+# This code match only a `config` directory in the root of the repository:
+/config/ @config-owner
+
+# If the path contains spaces, escape them like this:
+path\ with\ spaces/ @space-owner
+
+# Code Owners section:
+[Documentation]
+ee/docs @docs
+docs @docs
+
+# Use of default owners for a section. In this case, all files (*) are owned by
+the dev team except the README.md and data-models which are owned by other teams.
+[Development] @dev-team
+*
+README.md @docs-team
+data-models/ @data-science-team
+
+# This section is combined with the previously defined [Documentation] section:
+[DOCUMENTATION]
+README.md @docs
+```
diff --git a/doc/user/project/deploy_keys/index.md b/doc/user/project/deploy_keys/index.md
index 6aac0dba63d..92fdc59dde3 100644
--- a/doc/user/project/deploy_keys/index.md
+++ b/doc/user/project/deploy_keys/index.md
@@ -86,6 +86,7 @@ Prerequisites:
1. Complete the fields.
1. Optional. To grant `read-write` permission, select the **Grant write permissions to this key**
checkbox.
+1. Optional. Update the **Expiration date**.
A project deploy key is enabled when it is created. You can modify only a project deploy key's
name and permissions.
diff --git a/lib/api/deploy_keys.rb b/lib/api/deploy_keys.rb
index ffe0b6589bc..634d6052b99 100644
--- a/lib/api/deploy_keys.rb
+++ b/lib/api/deploy_keys.rb
@@ -104,6 +104,7 @@ module API
requires :key, type: String, desc: 'New deploy key'
requires :title, type: String, desc: "New deploy key's title"
optional :can_push, type: Boolean, desc: "Can deploy key push to the project's repository"
+ optional :expires_at, type: DateTime, desc: 'The expiration date of the SSH key in ISO 8601 format (YYYY-MM-DDTHH:MM:SSZ)'
end
# rubocop: disable CodeReuse/ActiveRecord
post ":id/deploy_keys" do
diff --git a/lib/gitlab/database/migration_helpers.rb b/lib/gitlab/database/migration_helpers.rb
index 91199c55b1d..291f483e6e4 100644
--- a/lib/gitlab/database/migration_helpers.rb
+++ b/lib/gitlab/database/migration_helpers.rb
@@ -15,6 +15,7 @@ module Gitlab
include RenameTableHelpers
include AsyncIndexes::MigrationHelpers
include AsyncConstraints::MigrationHelpers
+ include WraparoundVacuumHelpers
def define_batchable_model(table_name, connection: self.connection)
super(table_name, connection: connection)
diff --git a/lib/gitlab/database/migration_helpers/wraparound_vacuum_helpers.rb b/lib/gitlab/database/migration_helpers/wraparound_vacuum_helpers.rb
new file mode 100644
index 00000000000..01ff3dcbfb8
--- /dev/null
+++ b/lib/gitlab/database/migration_helpers/wraparound_vacuum_helpers.rb
@@ -0,0 +1,90 @@
+# frozen_string_literal: true
+
+module Gitlab
+ module Database
+ module MigrationHelpers
+ module WraparoundVacuumHelpers
+ class WraparoundCheck
+ WraparoundError = Class.new(StandardError)
+
+ def initialize(table_name, migration:)
+ @migration = migration
+ @table_name = table_name
+
+ validate_table_existence!
+ end
+
+ def execute
+ return if disabled?
+ return unless wraparound_vacuum.present?
+
+ log "Autovacuum with wraparound prevention mode is running on `#{table_name}`", title: true
+ log "This process prevents the migration from acquiring the necessary locks"
+ log "Query: `#{wraparound_vacuum[:query]}`"
+ log "Current duration: #{wraparound_vacuum[:duration].inspect}"
+ log "Process id: #{wraparound_vacuum[:pid]}"
+ log "You can wait until it completes or if absolutely necessary interrupt it using: " \
+ "`select pg_cancel_backend(#{wraparound_vacuum[:pid]});`"
+ log "Be aware that a new process will kick in immediately, so multiple interruptions " \
+ "might be required to time it right with the locks retry mechanism"
+ end
+
+ private
+
+ attr_reader :table_name
+
+ delegate :say, :connection, to: :@migration
+
+ def wraparound_vacuum
+ @wraparound_vacuum ||= transform_wraparound_vacuum
+ end
+
+ def transform_wraparound_vacuum
+ result = raw_wraparound_vacuum
+ values = Array.wrap(result.cast_values.first)
+
+ result.columns.zip(values).to_h.with_indifferent_access.compact
+ end
+
+ def raw_wraparound_vacuum
+ connection.select_all(<<~SQL.squish)
+ SELECT pid, state, age(clock_timestamp(), query_start) as duration, query
+ FROM pg_stat_activity
+ WHERE query ILIKE '%VACUUM%' || #{quoted_table_name} || '%(to prevent wraparound)'
+ AND backend_type = 'autovacuum worker'
+ LIMIT 1
+ SQL
+ end
+
+ def validate_table_existence!
+ return if connection.table_exists?(table_name)
+
+ raise WraparoundError, "Table #{table_name} does not exist"
+ end
+
+ def quoted_table_name
+ connection.quote(table_name)
+ end
+
+ def disabled?
+ return true unless wraparound_check_allowed?
+
+ Gitlab::Utils.to_boolean(ENV['GITLAB_MIGRATIONS_DISABLE_WRAPAROUND_CHECK'])
+ end
+
+ def wraparound_check_allowed?
+ Gitlab.com? || Gitlab.dev_or_test_env?
+ end
+
+ def log(text, title: false)
+ say text, !title
+ end
+ end
+
+ def check_if_wraparound_in_progress(table_name)
+ WraparoundCheck.new(table_name, migration: self).execute
+ end
+ end
+ end
+ end
+end
diff --git a/lib/product_analytics/settings.rb b/lib/product_analytics/settings.rb
new file mode 100644
index 00000000000..9e38adf8a13
--- /dev/null
+++ b/lib/product_analytics/settings.rb
@@ -0,0 +1,27 @@
+# frozen_string_literal: true
+
+module ProductAnalytics
+ class Settings
+ CONFIG_KEYS = (%w[jitsu_host jitsu_project_xid jitsu_administrator_email jitsu_administrator_password] +
+ %w[product_analytics_data_collector_host product_analytics_clickhouse_connection_string] +
+ %w[cube_api_base_url cube_api_key]).freeze
+
+ class << self
+ def enabled?
+ ::Gitlab::CurrentSettings.product_analytics_enabled? && configured?
+ end
+
+ def configured?
+ CONFIG_KEYS.all? do |key|
+ ::Gitlab::CurrentSettings.public_send(key)&.present? # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+
+ CONFIG_KEYS.each do |key|
+ define_method key.to_sym do
+ ::Gitlab::CurrentSettings.public_send(key) # rubocop:disable GitlabSecurity/PublicSend
+ end
+ end
+ end
+ end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 30bbd0214b6..b5802faba75 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -1860,6 +1860,9 @@ msgstr ""
msgid "AI|Explain the code from %{filePath} in human understandable language presented in Markdown format. In the response add neither original code snippet nor any title. `%{text}`"
msgstr ""
+msgid "AI|Give feedback on code explanation"
+msgstr ""
+
msgid "AI|Something went wrong. Please try again later"
msgstr ""
@@ -9131,6 +9134,9 @@ msgstr ""
msgid "CiCatalog|Repositories of pipeline components available in this namespace."
msgstr ""
+msgid "CiCatalog|There was an error fetching CI/CD Catalog resources."
+msgstr ""
+
msgid "CiCdAnalytics|Date range: %{range}"
msgstr ""
@@ -15919,6 +15925,12 @@ msgstr ""
msgid "Editing"
msgstr ""
+msgid "Editing markdown"
+msgstr ""
+
+msgid "Editing rich text"
+msgstr ""
+
msgid "Elapsed time"
msgstr ""
@@ -47960,6 +47972,9 @@ msgstr ""
msgid "UserProfile|Activity"
msgstr ""
+msgid "UserProfile|An error occurred loading the personal projects. Please refresh the page to try again."
+msgstr ""
+
msgid "UserProfile|Blocked user"
msgstr ""
@@ -48662,15 +48677,9 @@ msgstr ""
msgid "Viewing commit"
msgstr ""
-msgid "Viewing markdown"
-msgstr ""
-
msgid "Viewing projects and designs data from a primary site is not possible when using a unified URL. Visit the secondary site directly. %{geo_help_url}"
msgstr ""
-msgid "Viewing rich text"
-msgstr ""
-
msgid "Violation"
msgstr ""
@@ -50110,6 +50119,15 @@ msgstr ""
msgid "WorkItem|None"
msgstr ""
+msgid "WorkItem|Notifications"
+msgstr ""
+
+msgid "WorkItem|Notifications turned off."
+msgstr ""
+
+msgid "WorkItem|Notifications turned on."
+msgstr ""
+
msgid "WorkItem|Objective"
msgstr ""
diff --git a/qa/qa/page/project/settings/deploy_keys.rb b/qa/qa/page/project/settings/deploy_keys.rb
index 297d29550e3..b94dbbea533 100644
--- a/qa/qa/page/project/settings/deploy_keys.rb
+++ b/qa/qa/page/project/settings/deploy_keys.rb
@@ -13,6 +13,7 @@ module QA
view 'app/views/shared/deploy_keys/_project_group_form.html.haml' do
element :deploy_key_title_field
element :deploy_key_field
+ element :deploy_key_expires_at_field
element :add_deploy_key_button
end
diff --git a/spec/controllers/groups_controller_spec.rb b/spec/controllers/groups_controller_spec.rb
index 9184cd2263e..8617cc8af8f 100644
--- a/spec/controllers/groups_controller_spec.rb
+++ b/spec/controllers/groups_controller_spec.rb
@@ -152,29 +152,6 @@ RSpec.describe GroupsController, factory_default: :keep, feature_category: :code
end
end
end
-
- describe 'require_verification_for_namespace_creation experiment', :experiment do
- before do
- sign_in(owner)
- stub_experiments(require_verification_for_namespace_creation: :candidate)
- end
-
- it 'tracks a "start_create_group" event' do
- expect(experiment(:require_verification_for_namespace_creation)).to track(
- :start_create_group
- ).on_next_instance.with_context(user: owner)
-
- get :new
- end
-
- context 'when creating a sub-group' do
- it 'does not track a "start_create_group" event' do
- expect(experiment(:require_verification_for_namespace_creation)).not_to track(:start_create_group)
-
- get :new, params: { parent_id: group.id }
- end
- end
- end
end
describe 'GET #activity' do
diff --git a/spec/experiments/require_verification_for_namespace_creation_experiment_spec.rb b/spec/experiments/require_verification_for_namespace_creation_experiment_spec.rb
deleted file mode 100644
index c91a8f1950e..00000000000
--- a/spec/experiments/require_verification_for_namespace_creation_experiment_spec.rb
+++ /dev/null
@@ -1,49 +0,0 @@
-# frozen_string_literal: true
-
-require 'spec_helper'
-
-RSpec.describe RequireVerificationForNamespaceCreationExperiment, :experiment do
- subject(:experiment) { described_class.new(user: user) }
-
- let(:user_created_at) { RequireVerificationForNamespaceCreationExperiment::EXPERIMENT_START_DATE + 1.hour }
- let(:user) { create(:user, created_at: user_created_at) }
-
- describe '#candidate?' do
- context 'when experiment subject is candidate' do
- before do
- stub_experiments(require_verification_for_namespace_creation: :candidate)
- end
-
- it 'returns true' do
- expect(experiment.candidate?).to eq(true)
- end
- end
-
- context 'when experiment subject is control' do
- before do
- stub_experiments(require_verification_for_namespace_creation: :control)
- end
-
- it 'returns false' do
- expect(experiment.candidate?).to eq(false)
- end
- end
- end
-
- describe 'exclusions' do
- context 'when user is new' do
- it 'is not excluded' do
- expect(subject).not_to exclude(user: user)
- end
- end
-
- context 'when user is NOT new' do
- let(:user_created_at) { RequireVerificationForNamespaceCreationExperiment::EXPERIMENT_START_DATE - 1.day }
- let(:user) { create(:user, created_at: user_created_at) }
-
- it 'is excluded' do
- expect(subject).to exclude(user: user)
- end
- end
- end
-end
diff --git a/spec/features/emails/issues_spec.rb b/spec/features/emails/issues_spec.rb
index 13e62ee569a..c425dad88aa 100644
--- a/spec/features/emails/issues_spec.rb
+++ b/spec/features/emails/issues_spec.rb
@@ -4,71 +4,91 @@ require "spec_helper"
RSpec.describe "E-Mails > Issues", :js, feature_category: :team_planning do
let_it_be(:project) { create(:project_empty_repo, :public, name: 'Long Earth') }
- let_it_be(:assignee) { create(:user, username: 'assignee', name: 'Joshua Valienté') }
let_it_be(:author) { create(:user, username: 'author', name: 'Sally Linsay') }
let_it_be(:current_user) { create(:user, username: 'current_user', name: 'Shi-mi') }
- let_it_be(:issue_with_assignee) do
- create(
- :issue, project: project, author: author, assignees: [assignee],
- title: 'All your base are belong to us')
- end
-
- let_it_be(:issue_without_assignee) { create(:issue, project: project, author: author, title: 'No milk today!') }
before do
project.add_developer(current_user)
sign_in(current_user)
end
- it 'sends confirmation e-mail for assigning' do
- synchronous_notifications
- expect(Notify).to receive(:reassigned_issue_email)
- .with(author.id, issue_without_assignee.id, [], current_user.id, nil)
- .once
- .and_call_original
- expect(Notify).to receive(:reassigned_issue_email)
- .with(assignee.id, issue_without_assignee.id, [], current_user.id, NotificationReason::ASSIGNED)
- .once
- .and_call_original
-
- visit issue_path(issue_without_assignee)
- assign_to(assignee)
-
- expect(find('#notes-list')).to have_text("Shi-mi assigned to @assignee just now")
- end
+ describe 'assignees' do
+ let_it_be(:assignee) { create(:user, username: 'assignee', name: 'Joshua Valienté') }
+ let_it_be(:issue_without_assignee) { create(:issue, project: project, author: author, title: 'No milk today!') }
+
+ let_it_be(:issue_with_assignee) do
+ create(
+ :issue, project: project, author: author, assignees: [assignee],
+ title: 'All your base are belong to us')
+ end
+
+ it 'sends confirmation e-mail for assigning' do
+ synchronous_notifications
+ expect(Notify).to receive(:reassigned_issue_email)
+ .with(author.id, issue_without_assignee.id, [], current_user.id, nil)
+ .once
+ .and_call_original
+ expect(Notify).to receive(:reassigned_issue_email)
+ .with(assignee.id, issue_without_assignee.id, [], current_user.id, NotificationReason::ASSIGNED)
+ .once
+ .and_call_original
+
+ visit issue_path(issue_without_assignee)
+ assign_to(assignee)
+
+ expect(find('#notes-list')).to have_text("Shi-mi assigned to @assignee just now")
+ end
- it 'sends confirmation e-mail for reassigning' do
- synchronous_notifications
- expect(Notify).to receive(:reassigned_issue_email)
- .with(author.id, issue_with_assignee.id, [assignee.id], current_user.id, NotificationReason::ASSIGNED)
- .once
- .and_call_original
- expect(Notify).to receive(:reassigned_issue_email)
- .with(assignee.id, issue_with_assignee.id, [assignee.id], current_user.id, nil)
- .once
- .and_call_original
-
- visit issue_path(issue_with_assignee)
- assign_to(author)
-
- expect(find('#notes-list')).to have_text("Shi-mi assigned to @author and unassigned @assignee just now")
+ it 'sends confirmation e-mail for reassigning' do
+ synchronous_notifications
+ expect(Notify).to receive(:reassigned_issue_email)
+ .with(author.id, issue_with_assignee.id, [assignee.id], current_user.id, NotificationReason::ASSIGNED)
+ .once
+ .and_call_original
+ expect(Notify).to receive(:reassigned_issue_email)
+ .with(assignee.id, issue_with_assignee.id, [assignee.id], current_user.id, nil)
+ .once
+ .and_call_original
+
+ visit issue_path(issue_with_assignee)
+ assign_to(author)
+
+ expect(find('#notes-list')).to have_text("Shi-mi assigned to @author and unassigned @assignee just now")
+ end
+
+ it 'sends confirmation e-mail for unassigning' do
+ synchronous_notifications
+ expect(Notify).to receive(:reassigned_issue_email)
+ .with(author.id, issue_with_assignee.id, [assignee.id], current_user.id, nil)
+ .once
+ .and_call_original
+ expect(Notify).to receive(:reassigned_issue_email)
+ .with(assignee.id, issue_with_assignee.id, [assignee.id], current_user.id, nil)
+ .once
+ .and_call_original
+
+ visit issue_path(issue_with_assignee)
+ quick_action('/unassign')
+
+ expect(find('#notes-list')).to have_text("Shi-mi unassigned @assignee just now")
+ end
end
- it 'sends confirmation e-mail for unassigning' do
- synchronous_notifications
- expect(Notify).to receive(:reassigned_issue_email)
- .with(author.id, issue_with_assignee.id, [assignee.id], current_user.id, nil)
- .once
- .and_call_original
- expect(Notify).to receive(:reassigned_issue_email)
- .with(assignee.id, issue_with_assignee.id, [assignee.id], current_user.id, nil)
- .once
- .and_call_original
-
- visit issue_path(issue_with_assignee)
- quick_action('/unassign')
-
- expect(find('#notes-list')).to have_text("Shi-mi unassigned @assignee just now")
+ describe 'closing' do
+ let_it_be(:issue) { create(:issue, project: project, author: author, title: 'Public Holiday') }
+
+ it 'sends confirmation e-mail for closing' do
+ synchronous_notifications
+ expect(Notify).to receive(:closed_issue_email)
+ .with(author.id, issue.id, current_user.id, { closed_via: nil, reason: nil })
+ .once
+ .and_call_original
+
+ visit issue_path(issue)
+ quick_action("/close")
+
+ expect(find('#notes-list')).to have_text("Shi-mi closed just now")
+ end
end
private
diff --git a/spec/features/issues/user_edits_issue_spec.rb b/spec/features/issues/user_edits_issue_spec.rb
index 3a927e76fd1..c6cedbc83cd 100644
--- a/spec/features/issues/user_edits_issue_spec.rb
+++ b/spec/features/issues/user_edits_issue_spec.rb
@@ -118,7 +118,7 @@ RSpec.describe "Issues > User edits issue", :js, feature_category: :team_plannin
expect(issuable_form).to have_selector(markdown_field_focused_selector)
page.within issuable_form do
- click_on _('Viewing markdown')
+ click_on _('Editing markdown')
click_on _('Rich text')
end
@@ -131,7 +131,7 @@ RSpec.describe "Issues > User edits issue", :js, feature_category: :team_plannin
expect(issuable_form).to have_selector(content_editor_focused_selector)
page.within issuable_form do
- click_on _('Viewing rich text')
+ click_on _('Editing rich text')
click_on _('Markdown')
end
diff --git a/spec/features/projects/fork_spec.rb b/spec/features/projects/fork_spec.rb
index 8b484141a95..39cdc8faa85 100644
--- a/spec/features/projects/fork_spec.rb
+++ b/spec/features/projects/fork_spec.rb
@@ -119,16 +119,6 @@ RSpec.describe 'Project fork', feature_category: :projects do
end
end
- shared_examples "increments the fork counter on the source project's page" do
- specify :sidekiq_might_not_need_inline do
- create_forks
-
- visit project_path(project)
-
- expect(page).to have_css('.fork-count', text: 2)
- end
- end
-
it_behaves_like 'fork button on project page'
it_behaves_like 'create fork page', 'Fork project'
@@ -185,25 +175,17 @@ RSpec.describe 'Project fork', feature_category: :projects do
end
end
- context 'with cache_home_panel feature flag' do
+ context 'when user is a maintainer in multiple groups' do
before do
create(:group_member, :maintainer, user: user, group: group2)
end
- context 'when caching is enabled' do
- before do
- stub_feature_flags(cache_home_panel: project)
- end
-
- it_behaves_like "increments the fork counter on the source project's page"
- end
+ it "increments the fork counter on the source project's page", :sidekiq_might_not_need_inline do
+ create_forks
- context 'when caching is disabled' do
- before do
- stub_feature_flags(cache_home_panel: false)
- end
+ visit project_path(project)
- it_behaves_like "increments the fork counter on the source project's page"
+ expect(page).to have_css('.fork-count', text: 2)
end
end
end
diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb
index a0625c93b1a..5e45d1683e7 100644
--- a/spec/features/projects/settings/repository_settings_spec.rb
+++ b/spec/features/projects/settings/repository_settings_spec.rb
@@ -61,6 +61,10 @@ RSpec.describe 'Projects > Settings > Repository settings', feature_category: :p
let(:new_ssh_key) { attributes_for(:key)[:key] }
+ around do |example|
+ travel_to Time.zone.local(2022, 3, 1, 1, 0, 0) { example.run }
+ end
+
it 'get list of keys' do
project.deploy_keys << private_deploy_key
project.deploy_keys << public_deploy_key
@@ -83,6 +87,21 @@ RSpec.describe 'Projects > Settings > Repository settings', feature_category: :p
expect(page).to have_content('Grant write permissions to this key')
end
+ it 'add a new deploy key with expiration' do
+ one_month = Time.zone.local(2022, 4, 1, 1, 0, 0)
+ visit project_settings_repository_path(project)
+
+ fill_in 'deploy_key_title', with: 'new_deploy_key_with_expiry'
+ fill_in 'deploy_key_key', with: new_ssh_key
+ fill_in 'deploy_key_expires_at', with: one_month.to_s
+ check 'deploy_key_deploy_keys_projects_attributes_0_can_push'
+ click_button 'Add key'
+
+ expect(page).to have_content('new_deploy_key_with_expiry')
+ expect(page).to have_content('in 1 month')
+ expect(page).to have_content('Grant write permissions to this key')
+ end
+
it 'edit an existing deploy key' do
project.deploy_keys << private_deploy_key
visit project_settings_repository_path(project)
diff --git a/spec/features/projects/work_items/work_item_spec.rb b/spec/features/projects/work_items/work_item_spec.rb
index f1604a61890..b731c462f0c 100644
--- a/spec/features/projects/work_items/work_item_spec.rb
+++ b/spec/features/projects/work_items/work_item_spec.rb
@@ -33,6 +33,7 @@ RSpec.describe 'Work item', :js, feature_category: :team_planning do
it_behaves_like 'work items comments', :issue
it_behaves_like 'work items description'
it_behaves_like 'work items milestone'
+ it_behaves_like 'work items notifications'
end
context 'for signed in owner' do
@@ -58,4 +59,14 @@ RSpec.describe 'Work item', :js, feature_category: :team_planning do
it_behaves_like 'work items comment actions for guest users'
end
+
+ context 'for user not signed in' do
+ before do
+ visit work_items_path
+ end
+
+ it 'actions dropdown is not displayed' do
+ expect(page).not_to have_selector('[data-testid="work-item-actions-dropdown"]')
+ end
+ end
end
diff --git a/spec/frontend/api/user_api_spec.js b/spec/frontend/api/user_api_spec.js
index 6636d77a09b..a879c229581 100644
--- a/spec/frontend/api/user_api_spec.js
+++ b/spec/frontend/api/user_api_spec.js
@@ -1,6 +1,13 @@
import MockAdapter from 'axios-mock-adapter';
-import { followUser, unfollowUser, associationsCount, updateUserStatus } from '~/api/user_api';
+import projects from 'test_fixtures/api/users/projects/get.json';
+import {
+ followUser,
+ unfollowUser,
+ associationsCount,
+ updateUserStatus,
+ getUserProjects,
+} from '~/api/user_api';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import {
@@ -91,4 +98,18 @@ describe('~/api/user_api', () => {
expect(JSON.parse(axiosMock.history.patch[0].data)).toEqual(expectedData);
});
});
+
+ describe('getUserProjects', () => {
+ it('calls correct URL and returns expected response', async () => {
+ const expectedUrl = '/api/v4/users/1/projects';
+ const expectedResponse = { data: projects };
+
+ axiosMock.onGet(expectedUrl).replyOnce(HTTP_STATUS_OK, expectedResponse);
+
+ await expect(getUserProjects(1)).resolves.toEqual(
+ expect.objectContaining({ data: expectedResponse }),
+ );
+ expect(axiosMock.history.get[0].url).toBe(expectedUrl);
+ });
+ });
});
diff --git a/spec/frontend/deploy_keys/components/key_spec.js b/spec/frontend/deploy_keys/components/key_spec.js
index 5f20d4ad542..3c4fa2a6de6 100644
--- a/spec/frontend/deploy_keys/components/key_spec.js
+++ b/spec/frontend/deploy_keys/components/key_spec.js
@@ -1,9 +1,10 @@
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import data from 'test_fixtures/deploy_keys/keys.json';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import key from '~/deploy_keys/components/key.vue';
import DeployKeysStore from '~/deploy_keys/store';
-import { getTimeago } from '~/lib/utils/datetime_utility';
+import { getTimeago, formatDate } from '~/lib/utils/datetime_utility';
describe('Deploy keys key', () => {
let wrapper;
@@ -18,6 +19,9 @@ describe('Deploy keys key', () => {
endpoint: 'https://test.host/dummy/endpoint',
...propsData,
},
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
});
};
@@ -43,6 +47,33 @@ describe('Deploy keys key', () => {
);
});
+ it('renders human friendly expiration date', () => {
+ const expiresAt = new Date();
+ createComponent({
+ deployKey: { ...deployKey, expires_at: expiresAt },
+ });
+
+ expect(findTextAndTrim('.key-expires-at')).toBe(`${getTimeago().format(expiresAt)}`);
+ });
+ it('shows tooltip for expiration date', () => {
+ const expiresAt = new Date();
+ createComponent({
+ deployKey: { ...deployKey, expires_at: expiresAt },
+ });
+
+ const expiryComponent = wrapper.find('[data-testid="expires-at-tooltip"]');
+ const tooltip = getBinding(expiryComponent.element, 'gl-tooltip');
+ expect(tooltip).toBeDefined();
+ expect(expiryComponent.attributes('title')).toBe(`${formatDate(expiresAt)}`);
+ });
+ it('renders never when no expiration date', () => {
+ createComponent({
+ deployKey: { ...deployKey, expires_at: null },
+ });
+
+ expect(wrapper.find('[data-testid="expires-never"]').exists()).toBe(true);
+ });
+
it('shows pencil button for editing', () => {
createComponent({ deployKey });
diff --git a/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js
index d82b6c5fec0..c8fa02cb6aa 100644
--- a/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js
+++ b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js
@@ -1,7 +1,9 @@
import { GlAlert } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
-import { nextTick } from 'vue';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
import Autosave from '~/autosave';
+import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal';
import createNoteMutation from '~/design_management/graphql/mutations/create_note.mutation.graphql';
@@ -17,11 +19,14 @@ import {
mockNoteSubmitFailureMutationResponse,
} from '../../mock_data/apollo_mock';
+Vue.use(VueApollo);
+
jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal');
jest.mock('~/autosave');
describe('Design reply form component', () => {
let wrapper;
+ let mockApollo;
const findTextarea = () => wrapper.find('textarea');
const findSubmitButton = () => wrapper.findComponent({ ref: 'submitButton' });
@@ -32,14 +37,10 @@ describe('Design reply form component', () => {
const mockComment = 'New comment';
const mockDiscussionId = 'gid://gitlab/Discussion/6466a72f35b163f3c3e52d7976a09387f2c573e8';
const createNoteMutationData = {
- mutation: createNoteMutation,
- update: expect.anything(),
- variables: {
- input: {
- noteableId: mockNoteableId,
- discussionId: mockDiscussionId,
- body: mockComment,
- },
+ input: {
+ noteableId: mockNoteableId,
+ discussionId: mockDiscussionId,
+ body: mockComment,
},
};
@@ -49,14 +50,15 @@ describe('Design reply form component', () => {
const metaKey = {
metaKey: true,
};
- const mutationHandler = jest.fn().mockResolvedValue();
+ const mockMutationHandler = jest.fn().mockResolvedValue(mockNoteSubmitSuccessMutationResponse);
function createComponent({
props = {},
mountOptions = {},
data = {},
- mutation = mutationHandler,
+ mutationHandler = mockMutationHandler,
} = {}) {
+ mockApollo = createMockApollo([[createNoteMutation, mutationHandler]]);
wrapper = mount(DesignReplyForm, {
propsData: {
designNoteMutation: createNoteMutation,
@@ -67,11 +69,7 @@ describe('Design reply form component', () => {
...props,
},
...mountOptions,
- mocks: {
- $apollo: {
- mutate: mutation,
- },
- },
+ apolloProvider: mockApollo,
data() {
return {
...data,
@@ -85,6 +83,7 @@ describe('Design reply form component', () => {
});
afterEach(() => {
+ mockApollo = null;
confirmAction.mockReset();
});
@@ -127,7 +126,6 @@ describe('Design reply form component', () => {
'initializes autosave support on discussion with proper key',
async ({ discussionId, shortDiscussionId }) => {
createComponent({ props: { discussionId } });
- await nextTick();
expect(Autosave).toHaveBeenCalledWith(expect.any(Element), [
'Discussion',
@@ -140,7 +138,6 @@ describe('Design reply form component', () => {
describe('when form has no text', () => {
beforeEach(async () => {
createComponent();
- await nextTick();
});
it('submit button is disabled', () => {
@@ -154,8 +151,7 @@ describe('Design reply form component', () => {
`('does not perform mutation on textarea $key+enter keydown', async ({ keyData }) => {
findTextarea().trigger('keydown.enter', keyData);
- await nextTick();
- expect(mutationHandler).not.toHaveBeenCalled();
+ expect(mockMutationHandler).not.toHaveBeenCalled();
});
it('emits cancelForm event on pressing escape button on textarea', () => {
@@ -182,22 +178,20 @@ describe('Design reply form component', () => {
noteableId: mockNoteableId,
discussionId: mockDiscussionId,
};
- const successfulMutation = jest.fn().mockResolvedValue(mockNoteSubmitSuccessMutationResponse);
+
createComponent({
props: {
- designNoteMutation: createNoteMutation,
mutationVariables: mockMutationVariables,
value: mockComment,
},
- mutation: successfulMutation,
});
findSubmitButton().vm.$emit('click');
- await nextTick();
- expect(successfulMutation).toHaveBeenCalledWith(createNoteMutationData);
+ expect(mockMutationHandler).toHaveBeenCalledWith(createNoteMutationData);
await waitForPromises();
+
expect(wrapper.emitted('note-submit-complete')).toEqual([
[mockNoteSubmitSuccessMutationResponse],
]);
@@ -212,20 +206,17 @@ describe('Design reply form component', () => {
noteableId: mockNoteableId,
discussionId: mockDiscussionId,
};
- const successfulMutation = jest.fn().mockResolvedValue(mockNoteSubmitSuccessMutationResponse);
+
createComponent({
props: {
- designNoteMutation: createNoteMutation,
mutationVariables: mockMutationVariables,
value: mockComment,
},
- mutation: successfulMutation,
});
findTextarea().trigger('keydown.enter', keyData);
- await nextTick();
- expect(successfulMutation).toHaveBeenCalledWith(createNoteMutationData);
+ expect(mockMutationHandler).toHaveBeenCalledWith(createNoteMutationData);
await waitForPromises();
expect(wrapper.emitted('note-submit-complete')).toEqual([
@@ -240,7 +231,7 @@ describe('Design reply form component', () => {
designNoteMutation: createNoteMutation,
value: mockComment,
},
- mutation: failedMutation,
+ mutationHandler: failedMutation,
data: {
errorMessage: 'error',
},
@@ -280,7 +271,6 @@ describe('Design reply form component', () => {
findTextarea().setValue(mockComment);
- await nextTick();
findTextarea().trigger('keyup.esc');
expect(confirmAction).toHaveBeenCalled();
@@ -292,7 +282,6 @@ describe('Design reply form component', () => {
createComponent({ props: { value: mockComment } });
findTextarea().setValue('Comment changed');
- await nextTick();
findTextarea().trigger('keyup.esc');
expect(confirmAction).toHaveBeenCalled();
@@ -306,10 +295,8 @@ describe('Design reply form component', () => {
createComponent({ props: { value: mockComment } });
findTextarea().setValue('Comment changed');
- await nextTick();
findTextarea().trigger('keyup.esc');
- await nextTick();
expect(confirmAction).toHaveBeenCalled();
await waitForPromises();
diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js
index a7355719141..f8214558d74 100644
--- a/spec/frontend/design_management/mock_data/apollo_mock.js
+++ b/spec/frontend/design_management/mock_data/apollo_mock.js
@@ -214,64 +214,62 @@ export const getDesignQueryResponse = {
},
};
-export const mockNoteSubmitSuccessMutationResponse = [
- {
- data: {
- createNote: {
- note: {
- id: 'gid://gitlab/DiffNote/468',
- author: {
- id: 'gid://gitlab/User/1',
- avatarUrl:
- 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- name: 'Administrator',
- username: 'root',
- webUrl: 'http://127.0.0.1:3000/root',
- __typename: 'UserCore',
- },
- body: 'New comment',
- bodyHtml: "<p data-sourcepos='1:1-1:4' dir='auto'>asdd</p>",
- createdAt: '2023-02-24T06:49:20Z',
- resolved: false,
- position: {
- diffRefs: {
- baseSha: 'f63ae53ed82d8765477c191383e1e6a000c10375',
- startSha: 'f63ae53ed82d8765477c191383e1e6a000c10375',
- headSha: 'f348c652f1a737151fc79047895e695fbe81464c',
- __typename: 'DiffRefs',
- },
- x: 441,
- y: 128,
- height: 152,
- width: 695,
- __typename: 'DiffPosition',
- },
- userPermissions: {
- adminNote: true,
- repositionNote: true,
- __typename: 'NotePermissions',
+export const mockNoteSubmitSuccessMutationResponse = {
+ data: {
+ createNote: {
+ note: {
+ id: 'gid://gitlab/DiffNote/468',
+ author: {
+ id: 'gid://gitlab/User/1',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'http://127.0.0.1:3000/root',
+ __typename: 'UserCore',
+ },
+ body: 'New comment',
+ bodyHtml: "<p data-sourcepos='1:1-1:4' dir='auto'>asdd</p>",
+ createdAt: '2023-02-24T06:49:20Z',
+ resolved: false,
+ position: {
+ diffRefs: {
+ baseSha: 'f63ae53ed82d8765477c191383e1e6a000c10375',
+ startSha: 'f63ae53ed82d8765477c191383e1e6a000c10375',
+ headSha: 'f348c652f1a737151fc79047895e695fbe81464c',
+ __typename: 'DiffRefs',
},
- discussion: {
- id: 'gid://gitlab/Discussion/6466a72f35b163f3c3e52d7976a09387f2c573e8',
- notes: {
- nodes: [
- {
- id: 'gid://gitlab/DiffNote/459',
- __typename: 'Note',
- },
- ],
- __typename: 'NoteConnection',
- },
- __typename: 'Discussion',
+ x: 441,
+ y: 128,
+ height: 152,
+ width: 695,
+ __typename: 'DiffPosition',
+ },
+ userPermissions: {
+ adminNote: true,
+ repositionNote: true,
+ __typename: 'NotePermissions',
+ },
+ discussion: {
+ id: 'gid://gitlab/Discussion/6466a72f35b163f3c3e52d7976a09387f2c573e8',
+ notes: {
+ nodes: [
+ {
+ id: 'gid://gitlab/DiffNote/459',
+ __typename: 'Note',
+ },
+ ],
+ __typename: 'NoteConnection',
},
- __typename: 'Note',
+ __typename: 'Discussion',
},
- errors: [],
- __typename: 'CreateNotePayload',
+ __typename: 'Note',
},
+ errors: [],
+ __typename: 'CreateNotePayload',
},
},
-];
+};
export const mockNoteSubmitFailureMutationResponse = [
{
diff --git a/spec/frontend/fixtures/api_projects.rb b/spec/frontend/fixtures/api_projects.rb
index d1dfd223419..24c47d8d139 100644
--- a/spec/frontend/fixtures/api_projects.rb
+++ b/spec/frontend/fixtures/api_projects.rb
@@ -6,10 +6,11 @@ RSpec.describe API::Projects, '(JavaScript fixtures)', type: :request do
include ApiHelpers
include JavaScriptFixturesHelpers
- let(:namespace) { create(:namespace, name: 'gitlab-test') }
- let(:project) { create(:project, :repository, namespace: namespace, path: 'lorem-ipsum') }
- let(:project_empty) { create(:project_empty_repo, namespace: namespace, path: 'lorem-ipsum-empty') }
- let(:user) { project.owner }
+ let_it_be(:namespace) { create(:namespace, name: 'gitlab-test') }
+ let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'lorem-ipsum') }
+ let_it_be(:project_empty) { create(:project_empty_repo, namespace: namespace, path: 'lorem-ipsum-empty') }
+ let_it_be(:user) { project.owner }
+ let_it_be(:personal_projects) { create_list(:project, 3, namespace: user.namespace, topics: create_list(:topic, 5)) }
it 'api/projects/get.json' do
get api("/projects/#{project.id}", user)
@@ -28,4 +29,10 @@ RSpec.describe API::Projects, '(JavaScript fixtures)', type: :request do
expect(response).to be_successful
end
+
+ it 'api/users/projects/get.json' do
+ get api("/users/#{user.id}/projects", user)
+
+ expect(response).to be_successful
+ end
end
diff --git a/spec/frontend/profile/components/overview_tab_spec.js b/spec/frontend/profile/components/overview_tab_spec.js
index d4cb1dfd15d..aeab24cb730 100644
--- a/spec/frontend/profile/components/overview_tab_spec.js
+++ b/spec/frontend/profile/components/overview_tab_spec.js
@@ -1,15 +1,25 @@
-import { GlTab } from '@gitlab/ui';
+import { GlLoadingIcon, GlTab, GlLink } from '@gitlab/ui';
+import projects from 'test_fixtures/api/users/projects/get.json';
import { s__ } from '~/locale';
import OverviewTab from '~/profile/components/overview_tab.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ActivityCalendar from '~/profile/components/activity_calendar.vue';
+import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
describe('OverviewTab', () => {
let wrapper;
- const createComponent = () => {
- wrapper = shallowMountExtended(OverviewTab);
+ const defaultPropsData = {
+ personalProjects: convertObjectPropsToCamelCase(projects, { deep: true }),
+ personalProjectsLoading: false,
+ };
+
+ const createComponent = ({ propsData = {} } = {}) => {
+ wrapper = shallowMountExtended(OverviewTab, {
+ propsData: { ...defaultPropsData, ...propsData },
+ });
};
it('renders `GlTab` and sets `title` prop', () => {
@@ -23,4 +33,41 @@ describe('OverviewTab', () => {
expect(wrapper.findComponent(ActivityCalendar).exists()).toBe(true);
});
+
+ it('renders personal projects section heading and `View all` link', () => {
+ createComponent();
+
+ expect(
+ wrapper.findByRole('heading', { name: OverviewTab.i18n.personalProjects }).exists(),
+ ).toBe(true);
+ expect(wrapper.findComponent(GlLink).text()).toBe(OverviewTab.i18n.viewAll);
+ });
+
+ describe('when personal projects are loading', () => {
+ it('renders loading icon', () => {
+ createComponent({
+ propsData: {
+ personalProjects: [],
+ personalProjectsLoading: true,
+ },
+ });
+
+ expect(
+ wrapper.findByTestId('personal-projects-section').findComponent(GlLoadingIcon).exists(),
+ ).toBe(true);
+ });
+ });
+
+ describe('when projects are done loading', () => {
+ it('renders `ProjectsList` component and passes `projects` prop', () => {
+ createComponent();
+
+ expect(
+ wrapper
+ .findByTestId('personal-projects-section')
+ .findComponent(ProjectsList)
+ .props('projects'),
+ ).toMatchObject(defaultPropsData.personalProjects);
+ });
+ });
});
diff --git a/spec/frontend/profile/components/profile_tabs_spec.js b/spec/frontend/profile/components/profile_tabs_spec.js
index 11ab372f1dd..80a1ff422ab 100644
--- a/spec/frontend/profile/components/profile_tabs_spec.js
+++ b/spec/frontend/profile/components/profile_tabs_spec.js
@@ -1,6 +1,9 @@
+import projects from 'test_fixtures/api/users/projects/get.json';
import ProfileTabs from '~/profile/components/profile_tabs.vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-
+import { createAlert } from '~/alert';
+import { getUserProjects } from '~/rest_api';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import OverviewTab from '~/profile/components/overview_tab.vue';
import ActivityTab from '~/profile/components/activity_tab.vue';
import GroupsTab from '~/profile/components/groups_tab.vue';
@@ -10,12 +13,20 @@ import StarredProjectsTab from '~/profile/components/starred_projects_tab.vue';
import SnippetsTab from '~/profile/components/snippets_tab.vue';
import FollowersTab from '~/profile/components/followers_tab.vue';
import FollowingTab from '~/profile/components/following_tab.vue';
+import waitForPromises from 'helpers/wait_for_promises';
+
+jest.mock('~/alert');
+jest.mock('~/rest_api');
describe('ProfileTabs', () => {
let wrapper;
const createComponent = () => {
- wrapper = shallowMountExtended(ProfileTabs);
+ wrapper = shallowMountExtended(ProfileTabs, {
+ provide: {
+ userId: '1',
+ },
+ });
};
it.each([
@@ -33,4 +44,46 @@ describe('ProfileTabs', () => {
expect(wrapper.findComponent(tab).exists()).toBe(true);
});
+
+ describe('when personal projects API request is loading', () => {
+ beforeEach(() => {
+ getUserProjects.mockReturnValueOnce(new Promise(() => {}));
+ createComponent();
+ });
+
+ it('passes correct props to `OverviewTab` component', () => {
+ expect(wrapper.findComponent(OverviewTab).props()).toEqual({
+ personalProjects: [],
+ personalProjectsLoading: true,
+ });
+ });
+ });
+
+ describe('when personal projects API request is successful', () => {
+ beforeEach(async () => {
+ getUserProjects.mockResolvedValueOnce({ data: projects });
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('passes correct props to `OverviewTab` component', () => {
+ expect(wrapper.findComponent(OverviewTab).props()).toMatchObject({
+ personalProjects: convertObjectPropsToCamelCase(projects, { deep: true }),
+ personalProjectsLoading: false,
+ });
+ });
+ });
+
+ describe('when personal projects API request is not successful', () => {
+ beforeEach(() => {
+ getUserProjects.mockRejectedValueOnce();
+ createComponent();
+ });
+
+ it('calls `createAlert`', () => {
+ expect(createAlert).toHaveBeenCalledWith({
+ message: ProfileTabs.i18n.personalProjectsErrorMessage,
+ });
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/markdown/editor_mode_dropdown_spec.js b/spec/frontend/vue_shared/components/markdown/editor_mode_dropdown_spec.js
index becd4257cbe..fd8493e0911 100644
--- a/spec/frontend/vue_shared/components/markdown/editor_mode_dropdown_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/editor_mode_dropdown_spec.js
@@ -23,8 +23,8 @@ describe('vue_shared/component/markdown/editor_mode_dropdown', () => {
describe.each`
modeText | value | dropdownText | otherMode
- ${'Rich text'} | ${'richText'} | ${'Viewing rich text'} | ${'Markdown'}
- ${'Markdown'} | ${'markdown'} | ${'Viewing markdown'} | ${'Rich text'}
+ ${'Rich text'} | ${'richText'} | ${'Editing rich text'} | ${'Markdown'}
+ ${'Markdown'} | ${'markdown'} | ${'Editing markdown'} | ${'Rich text'}
`('$modeText', ({ modeText, value, dropdownText, otherMode }) => {
beforeEach(() => {
createComponent({ value });
diff --git a/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js
new file mode 100644
index 00000000000..a8e3536059e
--- /dev/null
+++ b/spec/frontend/vue_shared/components/projects_list/projects_list_item_spec.js
@@ -0,0 +1,169 @@
+import { GlAvatarLabeled, GlBadge, GlIcon } from '@gitlab/ui';
+import projects from 'test_fixtures/api/users/projects/get.json';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import ProjectsListItem from '~/vue_shared/components/projects_list/projects_list_item.vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import {
+ VISIBILITY_TYPE_ICON,
+ VISIBILITY_LEVEL_PRIVATE_STRING,
+ PROJECT_VISIBILITY_TYPE,
+} from '~/visibility_level/constants';
+import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
+import { ACCESS_LEVEL_LABELS } from '~/access_level/constants';
+import { FEATURABLE_DISABLED, FEATURABLE_ENABLED } from '~/featurable/constants';
+
+describe('ProjectsListItem', () => {
+ let wrapper;
+
+ const [project] = convertObjectPropsToCamelCase(projects, { deep: true });
+
+ const defaultPropsData = { project };
+
+ const createComponent = ({ propsData = {} } = {}) => {
+ wrapper = mountExtended(ProjectsListItem, {
+ propsData: { ...defaultPropsData, ...propsData },
+ directives: {
+ GlTooltip: createMockDirective('gl-tooltip'),
+ },
+ });
+ };
+
+ const findAvatarLabeled = () => wrapper.findComponent(GlAvatarLabeled);
+ const findIssuesLink = () => wrapper.findByRole('link', { name: ProjectsListItem.i18n.issues });
+ const findForksLink = () => wrapper.findByRole('link', { name: ProjectsListItem.i18n.forks });
+
+ it('renders project avatar', () => {
+ createComponent();
+
+ const avatarLabeled = findAvatarLabeled();
+
+ expect(avatarLabeled.props()).toMatchObject({
+ label: project.name,
+ labelLink: project.webUrl,
+ });
+ expect(avatarLabeled.attributes()).toMatchObject({
+ 'entity-id': project.id.toString(),
+ 'entity-name': project.name,
+ shape: 'rect',
+ size: '48',
+ });
+ });
+
+ it('renders visibility icon with tooltip', () => {
+ createComponent();
+
+ const icon = findAvatarLabeled().findComponent(GlIcon);
+ const tooltip = getBinding(icon.element, 'gl-tooltip');
+
+ expect(icon.props('name')).toBe(VISIBILITY_TYPE_ICON[VISIBILITY_LEVEL_PRIVATE_STRING]);
+ expect(tooltip.value).toBe(PROJECT_VISIBILITY_TYPE[VISIBILITY_LEVEL_PRIVATE_STRING]);
+ });
+
+ it('renders access role badge', () => {
+ createComponent();
+
+ expect(findAvatarLabeled().findComponent(UserAccessRoleBadge).text()).toBe(
+ ACCESS_LEVEL_LABELS[project.permissions.projectAccess.accessLevel],
+ );
+ });
+
+ describe('if project is archived', () => {
+ beforeEach(() => {
+ createComponent({
+ propsData: {
+ project: {
+ ...project,
+ archived: true,
+ },
+ },
+ });
+ });
+
+ it('renders the archived badge', () => {
+ expect(
+ wrapper
+ .findAllComponents(GlBadge)
+ .wrappers.find((badge) => badge.text() === ProjectsListItem.i18n.archived),
+ ).not.toBeUndefined();
+ });
+ });
+
+ it('renders stars count', () => {
+ createComponent();
+
+ const starsLink = wrapper.findByRole('link', { name: ProjectsListItem.i18n.stars });
+ const tooltip = getBinding(starsLink.element, 'gl-tooltip');
+
+ expect(tooltip.value).toBe(ProjectsListItem.i18n.stars);
+ expect(starsLink.attributes('href')).toBe(`${project.webUrl}/-/starrers`);
+ expect(starsLink.text()).toBe(project.starCount.toString());
+ expect(starsLink.findComponent(GlIcon).props('name')).toBe('star-o');
+ });
+
+ describe('when issues are enabled', () => {
+ it('renders issues count', () => {
+ createComponent();
+
+ const issuesLink = findIssuesLink();
+ const tooltip = getBinding(issuesLink.element, 'gl-tooltip');
+
+ expect(tooltip.value).toBe(ProjectsListItem.i18n.issues);
+ expect(issuesLink.attributes('href')).toBe(`${project.webUrl}/-/issues`);
+ expect(issuesLink.text()).toBe(project.openIssuesCount.toString());
+ expect(issuesLink.findComponent(GlIcon).props('name')).toBe('issues');
+ });
+ });
+
+ describe('when issues are not enabled', () => {
+ it('does not render issues count', () => {
+ createComponent({
+ propsData: {
+ project: {
+ ...project,
+ issuesAccessLevel: FEATURABLE_DISABLED,
+ },
+ },
+ });
+
+ expect(findIssuesLink().exists()).toBe(false);
+ });
+ });
+
+ describe('when forking is enabled', () => {
+ it('renders forks count', () => {
+ createComponent();
+
+ const forksLink = findForksLink();
+ const tooltip = getBinding(forksLink.element, 'gl-tooltip');
+
+ expect(tooltip.value).toBe(ProjectsListItem.i18n.forks);
+ expect(forksLink.attributes('href')).toBe(`${project.webUrl}/-/forks`);
+ expect(forksLink.text()).toBe(project.openIssuesCount.toString());
+ expect(forksLink.findComponent(GlIcon).props('name')).toBe('fork');
+ });
+ });
+
+ describe('when forking is not enabled', () => {
+ it.each([
+ {
+ ...project,
+ forksCount: 2,
+ forkingAccessLevel: FEATURABLE_DISABLED,
+ },
+ {
+ ...project,
+ forksCount: undefined,
+ forkingAccessLevel: FEATURABLE_ENABLED,
+ },
+ ])('does not render forks count', (modifiedProject) => {
+ createComponent({
+ propsData: {
+ project: modifiedProject,
+ },
+ });
+
+ expect(findForksLink().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js b/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js
new file mode 100644
index 00000000000..9380e19c39e
--- /dev/null
+++ b/spec/frontend/vue_shared/components/projects_list/projects_list_spec.js
@@ -0,0 +1,34 @@
+import projects from 'test_fixtures/api/users/projects/get.json';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import ProjectsList from '~/vue_shared/components/projects_list/projects_list.vue';
+import ProjectsListItem from '~/vue_shared/components/projects_list/projects_list_item.vue';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+
+describe('ProjectsList', () => {
+ let wrapper;
+
+ const defaultPropsData = {
+ projects: convertObjectPropsToCamelCase(projects, { deep: true }),
+ };
+
+ const createComponent = () => {
+ wrapper = shallowMountExtended(ProjectsList, {
+ propsData: defaultPropsData,
+ });
+ };
+
+ it('renders list with `ProjectListItem` component', () => {
+ createComponent();
+
+ const projectsListItemWrappers = wrapper.findAllComponents(ProjectsListItem).wrappers;
+ const expectedProps = projectsListItemWrappers.map((projectsListItemWrapper) =>
+ projectsListItemWrapper.props(),
+ );
+
+ expect(expectedProps).toEqual(
+ defaultPropsData.projects.map((project) => ({
+ project,
+ })),
+ );
+ });
+});
diff --git a/spec/frontend/work_items/components/work_item_actions_spec.js b/spec/frontend/work_items/components/work_item_actions_spec.js
index a0db8172bf6..5ae0635850d 100644
--- a/spec/frontend/work_items/components/work_item_actions_spec.js
+++ b/spec/frontend/work_items/components/work_item_actions_spec.js
@@ -1,17 +1,35 @@
-import { GlDropdownDivider, GlModal } from '@gitlab/ui';
+import { GlDropdownDivider, GlModal, GlToggle } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import { isLoggedIn } from '~/lib/utils/common_utils';
+import toast from '~/vue_shared/plugins/global_toast';
import WorkItemActions from '~/work_items/components/work_item_actions.vue';
-
-const TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION = 'confidentiality-toggle-action';
-const TEST_ID_DELETE_ACTION = 'delete-action';
+import {
+ TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION,
+ TEST_ID_NOTIFICATIONS_TOGGLE_ACTION,
+ TEST_ID_NOTIFICATIONS_TOGGLE_FORM,
+ TEST_ID_DELETE_ACTION,
+} from '~/work_items/constants';
+import updateWorkItemNotificationsMutation from '~/work_items/graphql/update_work_item_notifications.mutation.graphql';
+import { workItemResponseFactory } from '../mock_data';
+
+jest.mock('~/lib/utils/common_utils');
+jest.mock('~/vue_shared/plugins/global_toast');
describe('WorkItemActions component', () => {
+ Vue.use(VueApollo);
+
let wrapper;
let glModalDirective;
const findModal = () => wrapper.findComponent(GlModal);
const findConfidentialityToggleButton = () =>
wrapper.findByTestId(TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION);
+ const findNotificationsToggleButton = () =>
+ wrapper.findByTestId(TEST_ID_NOTIFICATIONS_TOGGLE_ACTION);
const findDeleteButton = () => wrapper.findByTestId(TEST_ID_DELETE_ACTION);
const findDropdownItems = () => wrapper.findAll('[data-testid="work-item-actions-dropdown"] > *');
const findDropdownItemsActual = () =>
@@ -25,20 +43,27 @@ describe('WorkItemActions component', () => {
text: x.text(),
};
});
+ const findNotificationsToggle = () => wrapper.findComponent(GlToggle);
const createComponent = ({
canUpdate = true,
canDelete = true,
isConfidential = false,
+ subscribed = false,
isParentConfidential = false,
+ notificationsMock = [updateWorkItemNotificationsMutation, jest.fn()],
} = {}) => {
+ const handlers = [notificationsMock];
glModalDirective = jest.fn();
wrapper = shallowMountExtended(WorkItemActions, {
+ apolloProvider: createMockApollo(handlers),
+ isLoggedIn: isLoggedIn(),
propsData: {
- workItemId: '123',
+ workItemId: 'gid://gitlab/WorkItem/1',
canUpdate,
canDelete,
isConfidential,
+ subscribed,
isParentConfidential,
workItemType: 'Task',
},
@@ -52,6 +77,10 @@ describe('WorkItemActions component', () => {
});
};
+ beforeEach(() => {
+ isLoggedIn.mockReturnValue(true);
+ });
+
it('renders modal', () => {
createComponent();
@@ -64,6 +93,13 @@ describe('WorkItemActions component', () => {
expect(findDropdownItemsActual()).toEqual([
{
+ testId: TEST_ID_NOTIFICATIONS_TOGGLE_FORM,
+ text: '',
+ },
+ {
+ divider: true,
+ },
+ {
testId: TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION,
text: 'Turn on confidentiality',
},
@@ -133,7 +169,75 @@ describe('WorkItemActions component', () => {
});
expect(findDeleteButton().exists()).toBe(false);
- expect(wrapper.findComponent(GlDropdownDivider).exists()).toBe(false);
+ });
+ });
+
+ describe('notifications action', () => {
+ const workItemQueryResponse = workItemResponseFactory({ canUpdate: true, canDelete: true });
+ const inputVariables = {
+ id: workItemQueryResponse.data.workItem.id,
+ notificationsWidget: {
+ subscribed: false,
+ },
+ };
+
+ const notificationExpectedResponse = workItemResponseFactory({
+ subscribed: false,
+ });
+
+ const toggleNotificationsHandler = jest.fn().mockResolvedValue({
+ data: {
+ workItemUpdate: {
+ workItem: notificationExpectedResponse.data.workItem,
+ errors: [],
+ },
+ },
+ });
+
+ const errorMessage = 'Failed to subscribe';
+ const toggleNotificationsFailureHandler = jest.fn().mockRejectedValue(new Error(errorMessage));
+
+ const notificationsMock = [updateWorkItemNotificationsMutation, toggleNotificationsHandler];
+
+ const notificationsFailureMock = [
+ updateWorkItemNotificationsMutation,
+ toggleNotificationsFailureHandler,
+ ];
+
+ beforeEach(() => {
+ createComponent();
+ isLoggedIn.mockReturnValue(true);
+ });
+
+ it('renders toggle button', () => {
+ expect(findNotificationsToggleButton().exists()).toBe(true);
+ });
+
+ it('calls notification mutation and displays a toast when the notification widget is toggled', async () => {
+ createComponent({ notificationsMock });
+
+ await waitForPromises();
+
+ findNotificationsToggle().vm.$emit('change', false);
+
+ await waitForPromises();
+
+ expect(notificationsMock[1]).toHaveBeenCalledWith({
+ input: inputVariables,
+ });
+ expect(toast).toHaveBeenCalledWith('Notifications turned off.');
+ });
+
+ it('emits error when the update notification mutation fails', async () => {
+ createComponent({ notificationsMock: notificationsFailureMock });
+
+ await waitForPromises();
+
+ findNotificationsToggle().vm.$emit('change', false);
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[errorMessage]]);
});
});
});
diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js
index 9d0a2344356..8e5b607cee7 100644
--- a/spec/frontend/work_items/components/work_item_detail_spec.js
+++ b/spec/frontend/work_items/components/work_item_detail_spec.js
@@ -35,6 +35,7 @@ import workItemAssigneesSubscription from '~/work_items/graphql/work_item_assign
import workItemMilestoneSubscription from '~/work_items/graphql/work_item_milestone.subscription.graphql';
import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql';
import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql';
+
import {
mockParent,
workItemDatesSubscriptionResponse,
diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js
index 50d7aff3365..c3376556d6e 100644
--- a/spec/frontend/work_items/mock_data.js
+++ b/spec/frontend/work_items/mock_data.js
@@ -288,6 +288,8 @@ export const objectiveType = {
export const workItemResponseFactory = ({
canUpdate = false,
canDelete = false,
+ notificationsWidgetPresent = true,
+ subscribed = true,
allowsMultipleAssignees = true,
assigneesWidgetPresent = true,
datesWidgetPresent = true,
@@ -472,6 +474,13 @@ export const workItemResponseFactory = ({
type: 'NOTES',
}
: { type: 'MOCK TYPE' },
+ notificationsWidgetPresent
+ ? {
+ __typename: 'WorkItemWidgetNotifications',
+ type: 'NOTIFICATIONS',
+ subscribed,
+ }
+ : { type: 'MOCK TYPE' },
],
},
},
diff --git a/spec/graphql/types/work_items/available_export_fields_enum_spec.rb b/spec/graphql/types/work_items/available_export_fields_enum_spec.rb
index 5aa51160880..9010aabe3cc 100644
--- a/spec/graphql/types/work_items/available_export_fields_enum_spec.rb
+++ b/spec/graphql/types/work_items/available_export_fields_enum_spec.rb
@@ -12,6 +12,7 @@ RSpec.describe GitlabSchema.types['AvailableExportFields'], feature_category: :t
'ID' | 'id'
'TYPE' | 'type'
'TITLE' | 'title'
+ 'DESCRIPTION' | 'description'
'AUTHOR' | 'author'
'AUTHOR_USERNAME' | 'author username'
'CREATED_AT' | 'created_at'
diff --git a/spec/helpers/users_helper_spec.rb b/spec/helpers/users_helper_spec.rb
index e1f474a0a3e..e99bb4859d5 100644
--- a/spec/helpers/users_helper_spec.rb
+++ b/spec/helpers/users_helper_spec.rb
@@ -520,7 +520,8 @@ RSpec.describe UsersHelper do
followees: 3,
followers: 2,
user_calendar_path: '/users/root/calendar.json',
- utc_offset: 0
+ utc_offset: 0,
+ user_id: user.id
})
end
end
diff --git a/spec/lib/gitlab/database/migration_helpers/wraparound_vacuum_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers/wraparound_vacuum_helpers_spec.rb
new file mode 100644
index 00000000000..eb67e81f677
--- /dev/null
+++ b/spec/lib/gitlab/database/migration_helpers/wraparound_vacuum_helpers_spec.rb
@@ -0,0 +1,99 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::Database::MigrationHelpers::WraparoundVacuumHelpers, feature_category: :database do
+ include Database::DatabaseHelpers
+
+ let(:table_name) { 'ci_builds' }
+
+ describe '#check_if_wraparound_in_progress' do
+ let(:migration) do
+ ActiveRecord::Migration.new.extend(described_class)
+ end
+
+ subject { migration.check_if_wraparound_in_progress(table_name) }
+
+ it 'delegates to the wraparound class' do
+ expect(described_class::WraparoundCheck)
+ .to receive(:new)
+ .with(table_name, migration: migration)
+ .and_call_original
+
+ expect { subject }.not_to raise_error
+ end
+ end
+
+ describe described_class::WraparoundCheck do
+ let(:migration) do
+ ActiveRecord::Migration.new.extend(Gitlab::Database::MigrationHelpers::WraparoundVacuumHelpers)
+ end
+
+ describe '#execute' do
+ subject do
+ described_class.new(table_name, migration: migration).execute
+ end
+
+ context 'with wraparound vacuuum running' do
+ before do
+ swapout_view_for_table(:pg_stat_activity, connection: migration.connection)
+
+ migration.connection.execute(<<~SQL.squish)
+ INSERT INTO pg_stat_activity (
+ datid, datname, pid, backend_start, xact_start, query_start,
+ state_change, wait_event_type, wait_event, state, backend_xmin,
+ query, backend_type)
+ VALUES (
+ 16401, 'gitlabhq_dblab', 178, '2023-03-30 08:10:50.851322+00',
+ '2023-03-30 08:10:50.890485+00', now() - '150 minutes'::interval,
+ '2023-03-30 08:10:50.890485+00', 'IO', 'DataFileRead', 'active','3214790381'::xid,
+ 'autovacuum: VACUUM public.ci_builds (to prevent wraparound)', 'autovacuum worker')
+ SQL
+ end
+
+ it 'outputs a message related to autovacuum' do
+ expect { subject }
+ .to output(/Autovacuum with wraparound prevention mode is running on `ci_builds`/).to_stdout
+ end
+
+ it { expect { subject }.to output(/autovacuum: VACUUM public.ci_builds \(to prevent wraparound\)/).to_stdout }
+ it { expect { subject }.to output(/Current duration: 2 hours, 30 minutes/).to_stdout }
+ it { expect { subject }.to output(/Process id: 178/).to_stdout }
+ it { expect { subject }.to output(/`select pg_cancel_backend\(178\);`/).to_stdout }
+
+ context 'when GITLAB_MIGRATIONS_DISABLE_WRAPAROUND_CHECK is set' do
+ before do
+ stub_env('GITLAB_MIGRATIONS_DISABLE_WRAPAROUND_CHECK' => 'true')
+ end
+
+ it { expect { subject }.not_to output(/autovacuum/i).to_stdout }
+
+ it 'is disabled on .com' do
+ expect(Gitlab).to receive(:com?).and_return(true)
+
+ expect { subject }.not_to raise_error
+ end
+ end
+
+ context 'when executed by self-managed' do
+ before do
+ allow(Gitlab).to receive(:com?).and_return(false)
+ allow(Gitlab).to receive(:dev_or_test_env?).and_return(false)
+ end
+
+ it { expect { subject }.not_to output(/autovacuum/i).to_stdout }
+ end
+ end
+
+ context 'with wraparound vacuuum not running' do
+ it { expect { subject }.not_to output(/autovacuum/i).to_stdout }
+ end
+
+ context 'when the table does not exist' do
+ let(:table_name) { :no_table }
+
+ it { expect { subject }.to raise_error described_class::WraparoundError, /no_table/ }
+ end
+ end
+ end
+end
diff --git a/spec/lib/gitlab/database/migration_helpers_spec.rb b/spec/lib/gitlab/database/migration_helpers_spec.rb
index d6e3cb6b8aa..a3eab560c67 100644
--- a/spec/lib/gitlab/database/migration_helpers_spec.rb
+++ b/spec/lib/gitlab/database/migration_helpers_spec.rb
@@ -14,6 +14,8 @@ RSpec.describe Gitlab::Database::MigrationHelpers, feature_category: :database d
allow(model).to receive(:puts)
end
+ it { expect(model.singleton_class.ancestors).to include(described_class::WraparoundVacuumHelpers) }
+
describe 'overridden dynamic model helpers' do
let(:test_table) { '_test_batching_table' }
diff --git a/spec/lib/product_analytics/settings_spec.rb b/spec/lib/product_analytics/settings_spec.rb
new file mode 100644
index 00000000000..2cacd55b871
--- /dev/null
+++ b/spec/lib/product_analytics/settings_spec.rb
@@ -0,0 +1,81 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe ProductAnalytics::Settings, feature_category: :product_analytics do
+ describe 'config settings' do
+ context 'when configured' do
+ before do
+ mock_settings('test')
+ end
+
+ it 'will be configured' do
+ expect(described_class.configured?).to be_truthy
+ end
+ end
+
+ context 'when not configured' do
+ before do
+ mock_settings('')
+ end
+
+ it 'will not be configured' do
+ expect(described_class.configured?).to be_falsey
+ end
+ end
+
+ context 'when one configuration setting is missing' do
+ before do
+ missing_key = ProductAnalytics::Settings::CONFIG_KEYS.last
+ mock_settings('test', ProductAnalytics::Settings::CONFIG_KEYS - [missing_key])
+ allow(::Gitlab::CurrentSettings).to receive(missing_key).and_return('')
+ end
+
+ it 'will not be configured' do
+ expect(described_class.configured?).to be_falsey
+ end
+ end
+
+ ProductAnalytics::Settings::CONFIG_KEYS.each do |key|
+ it "can read #{key}" do
+ expect(::Gitlab::CurrentSettings).to receive(key).and_return('test')
+
+ expect(described_class.send(key)).to eq('test')
+ end
+ end
+ end
+
+ describe '.enabled?' do
+ before do
+ allow(described_class).to receive(:configured?).and_return(true)
+ end
+
+ context 'when enabled' do
+ before do
+ allow(::Gitlab::CurrentSettings).to receive(:product_analytics_enabled?).and_return(true)
+ end
+
+ it 'will be enabled' do
+ expect(described_class.enabled?).to be_truthy
+ end
+ end
+
+ context 'when disabled' do
+ before do
+ allow(::Gitlab::CurrentSettings).to receive(:product_analytics_enabled?).and_return(false)
+ end
+
+ it 'will be enabled' do
+ expect(described_class.enabled?).to be_falsey
+ end
+ end
+ end
+
+ private
+
+ def mock_settings(setting, keys = ProductAnalytics::Settings::CONFIG_KEYS)
+ keys.each do |key|
+ allow(::Gitlab::CurrentSettings).to receive(key).and_return(setting)
+ end
+ end
+end
diff --git a/spec/mailers/notify_spec.rb b/spec/mailers/notify_spec.rb
index 38b83a1bb64..eb681846e82 100644
--- a/spec/mailers/notify_spec.rb
+++ b/spec/mailers/notify_spec.rb
@@ -143,6 +143,8 @@ RSpec.describe Notify do
it_behaves_like 'an unsubscribeable thread'
it_behaves_like 'appearance header and footer enabled'
it_behaves_like 'appearance header and footer not enabled'
+ it_behaves_like 'email with default notification reason'
+ it_behaves_like 'email with link to issue'
it 'is sent as the author' do
expect_sender(current_user)
@@ -152,11 +154,7 @@ RSpec.describe Notify do
aggregate_failures do
is_expected.to have_referable_subject(issue, reply: true)
is_expected.to have_body_text("Assignee changed from <strong>#{previous_assignee.name}</strong> to <strong>#{assignee.name}</strong>")
- is_expected.to have_body_text(%(<a href="#{project_issue_url(project, issue)}">view it on GitLab</a>))
- is_expected.to have_body_text("You're receiving this email because of your account")
is_expected.to have_plain_text_content("Assignee changed from #{previous_assignee.name} to #{assignee.name}")
- is_expected.to have_plain_text_content("view it on GitLab: #{project_issue_url(project, issue)}")
- is_expected.to have_plain_text_content("You're receiving this email because of your account")
end
end
@@ -165,26 +163,24 @@ RSpec.describe Notify do
issue.update!(assignees: [])
end
+ it_behaves_like 'email with default notification reason'
+ it_behaves_like 'email with link to issue'
+
it 'uses "Unassigned" placeholder' do
is_expected.to have_body_text("Assignee changed from <strong>#{previous_assignee.name}</strong> to <strong>Unassigned</strong>")
- is_expected.to have_body_text(%(<a href="#{project_issue_url(project, issue)}">view it on GitLab</a>))
- is_expected.to have_body_text("You're receiving this email because of your account")
is_expected.to have_plain_text_content("Assignee changed from #{previous_assignee.name} to Unassigned")
- is_expected.to have_plain_text_content("view it on GitLab: #{project_issue_url(project, issue)}")
- is_expected.to have_plain_text_content("You're receiving this email because of your account")
end
end
context 'without previous assignees' do
subject { described_class.reassigned_issue_email(recipient.id, issue.id, [], current_user.id) }
+ it_behaves_like 'email with default notification reason'
+ it_behaves_like 'email with link to issue'
+
it 'uses short text' do
is_expected.to have_body_text("Assignee changed to <strong>#{assignee.name}</strong>")
- is_expected.to have_body_text(%(<a href="#{project_issue_url(project, issue)}">view it on GitLab</a>))
- is_expected.to have_body_text("You're receiving this email because of your account")
is_expected.to have_plain_text_content("Assignee changed to #{assignee.name}")
- is_expected.to have_plain_text_content("view it on GitLab: #{project_issue_url(project, issue)}")
- is_expected.to have_plain_text_content("You're receiving this email because of your account")
end
end
@@ -301,6 +297,81 @@ RSpec.describe Notify do
end
end
+ describe 'closed' do
+ subject { described_class.closed_issue_email(recipient.id, issue.id, current_user.id) }
+
+ it_behaves_like 'an answer to an existing thread with reply-by-email enabled' do
+ let(:model) { issue }
+ end
+
+ it_behaves_like 'it should show Gmail Actions View Issue link'
+ it_behaves_like 'an unsubscribeable thread'
+ it_behaves_like 'appearance header and footer enabled'
+ it_behaves_like 'appearance header and footer not enabled'
+ it_behaves_like 'email with default notification reason'
+ it_behaves_like 'email with link to issue'
+
+ it 'is sent as the author' do
+ expect_sender(current_user)
+ end
+
+ it 'has the correct subject and body' do
+ aggregate_failures do
+ is_expected.to have_referable_subject(issue, reply: true)
+ is_expected.to have_body_text("Issue was closed by #{current_user_sanitized}")
+ is_expected.to have_plain_text_content("Issue was closed by #{current_user_sanitized}")
+ end
+ end
+
+ context 'via commit' do
+ let(:closing_commit) { project.commit }
+
+ subject { described_class.closed_issue_email(recipient.id, issue.id, current_user.id, closed_via: closing_commit.id) }
+
+ before do
+ allow(Ability).to receive(:allowed?).with(recipient, :mark_note_as_internal, anything).and_return(true)
+ allow(Ability).to receive(:allowed?).with(recipient, :download_code, project).and_return(true)
+ end
+
+ it_behaves_like 'email with default notification reason'
+ it_behaves_like 'email with link to issue'
+
+ it 'has the correct subject and body' do
+ aggregate_failures do
+ is_expected.to have_referable_subject(issue, reply: true)
+ is_expected.to have_body_text("Issue was closed by #{current_user_sanitized} via #{closing_commit.id}")
+ is_expected.to have_plain_text_content("Issue was closed by #{current_user_sanitized} via #{closing_commit.id}")
+ end
+ end
+ end
+
+ context 'via merge request' do
+ let(:closing_merge_request) { merge_request }
+
+ subject { described_class.closed_issue_email(recipient.id, issue.id, current_user.id, closed_via: closing_merge_request) }
+
+ before do
+ allow(Ability).to receive(:allowed?).with(recipient, :read_cross_project, :global).and_return(true)
+ allow(Ability).to receive(:allowed?).with(recipient, :mark_note_as_internal, anything).and_return(true)
+ allow(Ability).to receive(:allowed?).with(recipient, :read_merge_request, anything).and_return(true)
+ end
+
+ it_behaves_like 'email with default notification reason'
+ it_behaves_like 'email with link to issue'
+
+ it 'has the correct subject and body' do
+ aggregate_failures do
+ url = project_merge_request_url(project, closing_merge_request)
+ is_expected.to have_referable_subject(issue, reply: true)
+ is_expected.to have_body_text("Issue was closed by #{current_user_sanitized} via merge request " +
+ %(<a href="#{url}">#{closing_merge_request.to_reference}</a>))
+ is_expected.to have_plain_text_content("Issue was closed by #{current_user_sanitized} via merge request " \
+ "#{closing_merge_request.to_reference} (#{url})")
+ end
+ end
+ end
+ end
+
describe 'moved to another project' do
let(:new_issue) { create(:issue) }
@@ -2380,19 +2451,4 @@ RSpec.describe Notify do
expect(mail.body.parts.first.to_s).to include('Start a GitLab Ultimate trial today in less than one minute, no credit card required.')
end
end
-
- # can be replaced with https://github.com/email-spec/email-spec/pull/196 in the future
- RSpec::Matchers.define :have_plain_text_content do |expected_text|
- match do |actual_email|
- plain_text_body(actual_email).include? expected_text
- end
-
- failure_message do |actual_email|
- "Expected email\n#{plain_text_body(actual_email).indent(2)}\nto contain\n#{expected_text.indent(2)}"
- end
-
- def plain_text_body(email)
- email.text_part.body.to_s
- end
- end
end
diff --git a/spec/requests/api/deploy_keys_spec.rb b/spec/requests/api/deploy_keys_spec.rb
index a61a65fb87a..18a9211df3e 100644
--- a/spec/requests/api/deploy_keys_spec.rb
+++ b/spec/requests/api/deploy_keys_spec.rb
@@ -136,9 +136,25 @@ RSpec.describe API::DeployKeys, :aggregate_failures, feature_category: :continuo
expect(response).to have_gitlab_http_status(:not_found)
end
+
+ context 'when deploy key has expiry date' do
+ let(:deploy_key) { create(:deploy_key, :expired, public: true) }
+ let(:deploy_keys_project) { create(:deploy_keys_project, project: project, deploy_key: deploy_key) }
+
+ it 'returns expiry date' do
+ get api("#{project_path}/#{deploy_key.id}", admin, admin_mode: true)
+
+ expect(response).to have_gitlab_http_status(:ok)
+ expect(Time.parse(json_response['expires_at'])).to be_like_time(deploy_key.expires_at)
+ end
+ end
end
describe 'POST /projects/:id/deploy_keys' do
+ around do |example|
+ freeze_time { example.run }
+ end
+
it_behaves_like 'POST request permissions for admin mode', :not_found do
let(:params) { attributes_for :another_key }
let(:path) { project_path }
@@ -195,6 +211,15 @@ RSpec.describe API::DeployKeys, :aggregate_failures, feature_category: :continuo
expect(response).to have_gitlab_http_status(:created)
expect(json_response['can_push']).to eq(true)
end
+
+ it 'accepts expires_at parameter' do
+ key_attrs = attributes_for(:another_key).merge(expires_at: 2.days.since.iso8601)
+
+ post api(project_path, admin, admin_mode: true), params: key_attrs
+
+ expect(response).to have_gitlab_http_status(:created)
+ expect(Time.parse(json_response['expires_at'])).to be_like_time(2.days.since)
+ end
end
describe 'PUT /projects/:id/deploy_keys/:key_id' do
diff --git a/spec/requests/api/graphql/mutations/work_items/export_spec.rb b/spec/requests/api/graphql/mutations/work_items/export_spec.rb
index 3cadaab5201..d87fd5f84eb 100644
--- a/spec/requests/api/graphql/mutations/work_items/export_spec.rb
+++ b/spec/requests/api/graphql/mutations/work_items/export_spec.rb
@@ -35,7 +35,7 @@ RSpec.describe 'Export work items', feature_category: :team_planning do
let(:current_user) { reporter }
let(:input) do
super().merge(
- 'selectedFields' => %w[TITLE AUTHOR TYPE AUTHOR_USERNAME CREATED_AT],
+ 'selectedFields' => %w[TITLE DESCRIPTION AUTHOR TYPE AUTHOR_USERNAME CREATED_AT],
'authorUsername' => 'admin',
'iids' => [work_item.iid.to_s],
'state' => 'opened',
@@ -47,7 +47,7 @@ RSpec.describe 'Export work items', feature_category: :team_planning do
it 'schedules export job with given arguments', :aggregate_failures do
expected_arguments = {
- selected_fields: ['title', 'author', 'type', 'author username', 'created_at'],
+ selected_fields: ['title', 'description', 'author', 'type', 'author username', 'created_at'],
author_username: 'admin',
iids: [work_item.iid.to_s],
state: 'opened',
diff --git a/spec/serializers/deploy_keys/basic_deploy_key_entity_spec.rb b/spec/serializers/deploy_keys/basic_deploy_key_entity_spec.rb
index 7ea72351594..7df6413f416 100644
--- a/spec/serializers/deploy_keys/basic_deploy_key_entity_spec.rb
+++ b/spec/serializers/deploy_keys/basic_deploy_key_entity_spec.rb
@@ -29,6 +29,7 @@ RSpec.describe DeployKeys::BasicDeployKeyEntity do
destroyed_when_orphaned: true,
almost_orphaned: false,
created_at: deploy_key.created_at,
+ expires_at: deploy_key.expires_at,
updated_at: deploy_key.updated_at,
can_edit: false
}
diff --git a/spec/serializers/deploy_keys/deploy_key_entity_spec.rb b/spec/serializers/deploy_keys/deploy_key_entity_spec.rb
index 4302ed3a097..837e30e1343 100644
--- a/spec/serializers/deploy_keys/deploy_key_entity_spec.rb
+++ b/spec/serializers/deploy_keys/deploy_key_entity_spec.rb
@@ -29,6 +29,7 @@ RSpec.describe DeployKeys::DeployKeyEntity do
destroyed_when_orphaned: true,
almost_orphaned: false,
created_at: deploy_key.created_at,
+ expires_at: deploy_key.expires_at,
updated_at: deploy_key.updated_at,
can_edit: false,
deploy_keys_projects: [
diff --git a/spec/serializers/group_deploy_key_entity_spec.rb b/spec/serializers/group_deploy_key_entity_spec.rb
index e6cef2f10b3..c502923db6a 100644
--- a/spec/serializers/group_deploy_key_entity_spec.rb
+++ b/spec/serializers/group_deploy_key_entity_spec.rb
@@ -25,6 +25,7 @@ RSpec.describe GroupDeployKeyEntity do
fingerprint: group_deploy_key.fingerprint,
fingerprint_sha256: group_deploy_key.fingerprint_sha256,
created_at: group_deploy_key.created_at,
+ expires_at: group_deploy_key.expires_at,
updated_at: group_deploy_key.updated_at,
can_edit: false,
group_deploy_keys_groups: [
diff --git a/spec/services/issues/close_service_spec.rb b/spec/services/issues/close_service_spec.rb
index a1df0a15e06..47925236a74 100644
--- a/spec/services/issues/close_service_spec.rb
+++ b/spec/services/issues/close_service_spec.rb
@@ -202,34 +202,17 @@ RSpec.describe Issues::CloseService, feature_category: :team_planning do
end
it 'mentions closure via a merge request' do
- close_issue
-
- email = ActionMailer::Base.deliveries.last
+ expect_next_instance_of(NotificationService::Async) do |service|
+ expect(service).to receive(:close_issue).with(issue, user, { closed_via: closing_merge_request })
+ end
- expect(email.to.first).to eq(user2.email)
- expect(email.subject).to include(issue.title)
- expect(email.body.parts.map(&:body)).to all(include(closing_merge_request.to_reference))
+ close_issue
end
it_behaves_like 'records an onboarding progress action', :issue_auto_closed do
let(:namespace) { project.namespace }
end
- context 'when user cannot read merge request' do
- it 'does not mention merge request' do
- project.project_feature.update_attribute(:repository_access_level, ProjectFeature::DISABLED)
-
- close_issue
-
- email = ActionMailer::Base.deliveries.last
- body_text = email.body.parts.map(&:body).join(" ")
-
- expect(email.to.first).to eq(user2.email)
- expect(email.subject).to include(issue.title)
- expect(body_text).not_to include(closing_merge_request.to_reference)
- end
- end
-
context 'updating `metrics.first_mentioned_in_commit_at`' do
context 'when `metrics.first_mentioned_in_commit_at` is not set' do
it 'uses the first commit authored timestamp' do
@@ -265,31 +248,11 @@ RSpec.describe Issues::CloseService, feature_category: :team_planning do
context "closed by a commit", :sidekiq_might_not_need_inline do
it 'mentions closure via a commit' do
- perform_enqueued_jobs do
- described_class.new(container: project, current_user: user).close_issue(issue, closed_via: closing_commit)
+ expect_next_instance_of(NotificationService::Async) do |service|
+ expect(service).to receive(:close_issue).with(issue, user, { closed_via: "commit #{closing_commit.id}" })
end
- email = ActionMailer::Base.deliveries.last
-
- expect(email.to.first).to eq(user2.email)
- expect(email.subject).to include(issue.title)
- expect(email.body.parts.map(&:body)).to all(include(closing_commit.id))
- end
-
- context 'when user cannot read the commit' do
- it 'does not mention the commit id' do
- project.project_feature.update_attribute(:repository_access_level, ProjectFeature::DISABLED)
- perform_enqueued_jobs do
- described_class.new(container: project, current_user: user).close_issue(issue, closed_via: closing_commit)
- end
-
- email = ActionMailer::Base.deliveries.last
- body_text = email.body.parts.map(&:body).join(" ")
-
- expect(email.to.first).to eq(user2.email)
- expect(email.subject).to include(issue.title)
- expect(body_text).not_to include(closing_commit.id)
- end
+ described_class.new(container: project, current_user: user).close_issue(issue, closed_via: closing_commit)
end
end
@@ -321,12 +284,12 @@ RSpec.describe Issues::CloseService, feature_category: :team_planning do
expect(issue.reload.closed_by_id).to be(user.id)
end
- it 'sends email to user2 about assign of new issue', :sidekiq_might_not_need_inline do
- close_issue
+ it 'sends notification', :sidekiq_might_not_need_inline do
+ expect_next_instance_of(NotificationService::Async) do |service|
+ expect(service).to receive(:close_issue).with(issue, user, { closed_via: nil })
+ end
- email = ActionMailer::Base.deliveries.last
- expect(email.to.first).to eq(user2.email)
- expect(email.subject).to include(issue.title)
+ close_issue
end
it 'creates resource state event about the issue being closed' do
diff --git a/spec/support/helpers/content_editor_helpers.rb b/spec/support/helpers/content_editor_helpers.rb
index a3c5139e757..1bbc05cc05a 100644
--- a/spec/support/helpers/content_editor_helpers.rb
+++ b/spec/support/helpers/content_editor_helpers.rb
@@ -2,7 +2,7 @@
module ContentEditorHelpers
def switch_to_content_editor
- click_button _('Viewing markdown')
+ click_button _('Editing markdown')
click_button _('Rich text')
end
diff --git a/spec/support/matchers/have_plain_text_content.rb b/spec/support/matchers/have_plain_text_content.rb
new file mode 100644
index 00000000000..94f65ce3771
--- /dev/null
+++ b/spec/support/matchers/have_plain_text_content.rb
@@ -0,0 +1,16 @@
+# frozen_string_literal: true
+
+# can be replaced with https://github.com/email-spec/email-spec/pull/196 in the future
+RSpec::Matchers.define :have_plain_text_content do |expected_text|
+ match do |actual_email|
+ plain_text_body(actual_email).include? expected_text
+ end
+
+ failure_message do |actual_email|
+ "Expected email\n#{plain_text_body(actual_email).indent(2)}\nto contain\n#{expected_text.indent(2)}"
+ end
+
+ def plain_text_body(email)
+ email.text_part.body.to_s
+ end
+end
diff --git a/spec/support/rspec_order_todo.yml b/spec/support/rspec_order_todo.yml
index 1c26a34010c..771ace00a31 100644
--- a/spec/support/rspec_order_todo.yml
+++ b/spec/support/rspec_order_todo.yml
@@ -3603,7 +3603,6 @@
- './spec/experiments/force_company_trial_experiment_spec.rb'
- './spec/experiments/in_product_guidance_environments_webide_experiment_spec.rb'
- './spec/experiments/ios_specific_templates_experiment_spec.rb'
-- './spec/experiments/require_verification_for_namespace_creation_experiment_spec.rb'
- './spec/features/abuse_report_spec.rb'
- './spec/features/action_cable_logging_spec.rb'
- './spec/features/admin/admin_abuse_reports_spec.rb'
diff --git a/spec/support/shared_examples/features/content_editor_shared_examples.rb b/spec/support/shared_examples/features/content_editor_shared_examples.rb
index fcf4ef83f18..55104157f20 100644
--- a/spec/support/shared_examples/features/content_editor_shared_examples.rb
+++ b/spec/support/shared_examples/features/content_editor_shared_examples.rb
@@ -1,5 +1,7 @@
# frozen_string_literal: true
+require 'spec_helper'
+
RSpec.shared_examples 'edits content using the content editor' do
include ContentEditorHelpers
@@ -394,6 +396,62 @@ RSpec.shared_examples 'edits content using the content editor' do
end
end
+ describe 'pasting text' do
+ let(:is_mac) { page.evaluate_script('navigator.platform').include?('Mac') }
+ let(:modifier_key) { is_mac ? :command : :control }
+
+ before do
+ switch_to_content_editor
+
+ type_in_content_editor "Some **rich** _text_ ~~content~~ [link](https://gitlab.com)"
+
+ type_in_content_editor [modifier_key, 'a']
+ type_in_content_editor [modifier_key, 'x']
+ end
+
+ it 'pastes text with formatting if ctrl + v is pressed' do
+ type_in_content_editor [modifier_key, 'v']
+
+ page.within content_editor_testid do
+ expect(page).to have_selector('strong', text: 'rich')
+ expect(page).to have_selector('em', text: 'text')
+ expect(page).to have_selector('s', text: 'content')
+ expect(page).to have_selector('a[href="https://gitlab.com"]', text: 'link')
+ end
+ end
+
+ it 'pastes raw text without formatting if shift + ctrl + v is pressed' do
+ type_in_content_editor [modifier_key, :shift, 'v']
+
+ page.within content_editor_testid do
+ expect(page).to have_text('Some rich text content link')
+
+ expect(page).not_to have_selector('strong')
+ expect(page).not_to have_selector('em')
+ expect(page).not_to have_selector('s')
+ expect(page).not_to have_selector('a')
+ end
+ end
+
+ it 'pastes raw text without formatting, stripping whitespaces, if shift + ctrl + v is pressed' do
+ type_in_content_editor " Some **rich**"
+ type_in_content_editor :enter
+ type_in_content_editor " _text_"
+ type_in_content_editor :enter
+ type_in_content_editor " ~~content~~"
+ type_in_content_editor :enter
+ type_in_content_editor " [link](https://gitlab.com)"
+
+ type_in_content_editor [modifier_key, 'a']
+ type_in_content_editor [modifier_key, 'x']
+ type_in_content_editor [modifier_key, :shift, 'v']
+
+ page.within content_editor_testid do
+ expect(page).to have_text('Some rich text content link')
+ end
+ end
+ end
+
describe 'autocomplete suggestions' do
let(:suggestions_dropdown) { '[data-testid="content-editor-suggestions-dropdown"]' }
diff --git a/spec/support/shared_examples/features/deploy_token_shared_examples.rb b/spec/support/shared_examples/features/deploy_token_shared_examples.rb
index 9fe08e5c996..80f5f1d805c 100644
--- a/spec/support/shared_examples/features/deploy_token_shared_examples.rb
+++ b/spec/support/shared_examples/features/deploy_token_shared_examples.rb
@@ -17,9 +17,11 @@ RSpec.shared_examples 'a deploy token in settings' do
it 'add a new deploy token', :js do
visit page_path
- fill_in _('Name'), with: 'new_deploy_key'
- fill_in _('Expiration date (optional)'), with: (Date.today + 1.month).to_s
- fill_in _('Username (optional)'), with: 'deployer'
+ within('#js-deploy-tokens') do
+ fill_in _('Name'), with: 'new_deploy_key'
+ fill_in _('Expiration date (optional)'), with: (Date.today + 1.month).to_s
+ fill_in _('Username (optional)'), with: 'deployer'
+ end
check 'read_repository'
check 'read_registry'
click_button 'Create deploy token'
diff --git a/spec/support/shared_examples/features/work_items_shared_examples.rb b/spec/support/shared_examples/features/work_items_shared_examples.rb
index 759cdc423e2..6d4d0a5dd0a 100644
--- a/spec/support/shared_examples/features/work_items_shared_examples.rb
+++ b/spec/support/shared_examples/features/work_items_shared_examples.rb
@@ -285,3 +285,25 @@ RSpec.shared_examples 'work items comment actions for guest users' do
end
end
end
+
+RSpec.shared_examples 'work items notifications' do
+ let(:actions_dropdown_selector) { '[data-testid="work-item-actions-dropdown"]' }
+ let(:notifications_toggle_selector) { '[data-testid="notifications-toggle-action"] > button' }
+
+ it 'displays toast when notification is toggled' do
+ find(actions_dropdown_selector).click
+
+ page.within('[data-testid="notifications-toggle-form"]') do
+ expect(page).not_to have_css(".is-checked")
+
+ find(notifications_toggle_selector).click
+ wait_for_requests
+
+ expect(page).to have_css(".is-checked")
+ end
+
+ page.within('.gl-toast') do
+ expect(find('.toast-body')).to have_content(_('Notifications turned on.'))
+ end
+ end
+end
diff --git a/spec/support/shared_examples/mailers/notify_shared_examples.rb b/spec/support/shared_examples/mailers/notify_shared_examples.rb
index 2e182fb399d..4d039eccbf7 100644
--- a/spec/support/shared_examples/mailers/notify_shared_examples.rb
+++ b/spec/support/shared_examples/mailers/notify_shared_examples.rb
@@ -294,3 +294,17 @@ RSpec.shared_examples 'does not render a manage notifications link' do
end
end
end
+
+RSpec.shared_examples 'email with default notification reason' do
+ it do
+ is_expected.to have_body_text("You're receiving this email because of your account")
+ is_expected.to have_plain_text_content("You're receiving this email because of your account")
+ end
+end
+
+RSpec.shared_examples 'email with link to issue' do
+ it do
+ is_expected.to have_body_text(%(<a href="#{project_issue_url(project, issue)}">view it on GitLab</a>))
+ is_expected.to have_plain_text_content("view it on GitLab: #{project_issue_url(project, issue)}")
+ end
+end