Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2023-04-14 18:08:53 +00:00
parent cdb41961fd
commit 5b62f8e3ee
105 changed files with 2291 additions and 1017 deletions

View File

@ -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'

View File

@ -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'),
};

View File

@ -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 }) {

View File

@ -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)) : {};

View File

@ -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);

View File

@ -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') }}

View File

@ -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"

View File

@ -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';

View File

@ -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';

View File

@ -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;
},

View File

@ -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'),

View File

@ -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, {

View File

@ -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>

View File

@ -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>

View File

@ -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);

View File

@ -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();

View File

@ -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);
},

View File

@ -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',
};

View File

@ -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');
},
},
};

View File

@ -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>

View File

@ -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>

View File

@ -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
>

View File

@ -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"

View File

@ -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';

View File

@ -0,0 +1,13 @@
mutation updateWorkItemNotificationsWidget($input: WorkItemUpdateInput!) {
workItemUpdate(input: $input) {
workItem {
id
widgets {
... on WorkItemWidgetNotifications {
type
subscribed
}
}
}
}
}

View File

@ -36,4 +36,9 @@ fragment WorkItemMetadataWidgets on WorkItemWidget {
}
}
}
... on WorkItemWidgetNotifications {
type
subscribed
}
}

View File

@ -85,4 +85,8 @@ fragment WorkItemWidgets on WorkItemWidget {
... on WorkItemWidgetNotes {
type
}
... on WorkItemWidgetNotifications {
type
subscribed
}
}

View File

@ -1,6 +1,5 @@
@import './pages/colors';
@import './pages/commits';
@import './pages/detail_page';
@import './pages/events';
@import './pages/groups';
@import './pages/hierarchy';

View File

@ -93,3 +93,14 @@
top: -8px;
}
}
.work-item-notifications-form {
.gl-toggle {
@include gl-ml-auto;
}
.gl-toggle-label {
@include gl-font-weight-normal;
}
}

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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?)

View File

@ -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

View File

@ -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.'

View File

@ -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,

View File

@ -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

View File

@ -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) }

View File

@ -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])

View File

@ -29,3 +29,5 @@ module Branches
end
end
end
Branches::ValidateNewService.prepend_mod

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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 }

View File

@ -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

View File

@ -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

View File

@ -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
}
```

View File

@ -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. |

View File

@ -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

View File

@ -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. |
| `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`. |
| `ref` | Always | Git ref for the job. |
| `user_email` | Always | Email of the user executing the job. |
| `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. |
Example ID token payload:
| `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). |
| `job_id` | Always | ID of the job. |
| `ref` | Always | Git ref for the job. |
| `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"
}
```

View File

@ -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
---

View File

@ -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:

View File

@ -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).

View File

@ -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.

View File

@ -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:

View File

@ -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
```

View File

@ -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.

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -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

View File

@ -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 ""

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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
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!') }
visit issue_path(issue_without_assignee)
assign_to(assignee)
let_it_be(:issue_with_assignee) do
create(
:issue, project: project, author: author, assignees: [assignee],
title: 'All your base are belong to us')
end
expect(find('#notes-list')).to have_text("Shi-mi assigned to @assignee just now")
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")
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 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
describe 'closing' do
let_it_be(:issue) { create(:issue, project: project, author: author, title: 'Public Holiday') }
visit issue_path(issue_with_assignee)
assign_to(author)
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
expect(find('#notes-list')).to have_text("Shi-mi assigned to @author and unassigned @assignee just now")
end
visit issue_path(issue)
quick_action("/close")
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")
expect(find('#notes-list')).to have_text("Shi-mi closed just now")
end
end
private

View File

@ -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

View File

@ -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 "increments the fork counter on the source project's page", :sidekiq_might_not_need_inline do
create_forks
it_behaves_like "increments the fork counter on the source project's page"
end
visit project_path(project)
context 'when caching is disabled' do
before do
stub_feature_flags(cache_home_panel: false)
end
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

View File

@ -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)

View File

@ -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

View File

@ -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);
});
});
});

View File

@ -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 });

View File

@ -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();

View File

@ -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',
},
discussion: {
id: 'gid://gitlab/Discussion/6466a72f35b163f3c3e52d7976a09387f2c573e8',
notes: {
nodes: [
{
id: 'gid://gitlab/DiffNote/459',
__typename: 'Note',
},
],
__typename: 'NoteConnection',
},
__typename: 'Discussion',
},
__typename: 'Note',
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',
},
errors: [],
__typename: 'CreateNotePayload',
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',
},
discussion: {
id: 'gid://gitlab/Discussion/6466a72f35b163f3c3e52d7976a09387f2c573e8',
notes: {
nodes: [
{
id: 'gid://gitlab/DiffNote/459',
__typename: 'Note',
},
],
__typename: 'NoteConnection',
},
__typename: 'Discussion',
},
__typename: 'Note',
},
errors: [],
__typename: 'CreateNotePayload',
},
},
];
};
export const mockNoteSubmitFailureMutationResponse = [
{

View File

@ -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

View File

@ -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);
});
});
});

View File

@ -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,
});
});
});
});

View File

@ -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 });

View File

@ -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);
});
});
});

View File

@ -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,
})),
);
});
});

View File

@ -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';
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';
const TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION = 'confidentiality-toggle-action';
const TEST_ID_DELETE_ACTION = 'delete-action';
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();
@ -63,6 +92,13 @@ describe('WorkItemActions component', () => {
createComponent();
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]]);
});
});
});

View File

@ -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,

View File

@ -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' },
],
},
},

View File

@ -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'

View File

@ -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

View File

@ -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

View File

@ -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' }

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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',

View File

@ -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
}

View File

@ -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: [

View File

@ -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: [

View File

@ -202,34 +202,17 @@ RSpec.describe Issues::CloseService, feature_category: :team_planning do
end
it 'mentions closure via a merge request' do
expect_next_instance_of(NotificationService::Async) do |service|
expect(service).to receive(:close_issue).with(issue, user, { closed_via: closing_merge_request })
end
close_issue
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_merge_request.to_reference))
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

View File

@ -2,7 +2,7 @@
module ContentEditorHelpers
def switch_to_content_editor
click_button _('Viewing markdown')
click_button _('Editing markdown')
click_button _('Rich text')
end

View File

@ -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

Some files were not shown because too many files have changed in this diff Show More