Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
cdb41961fd
commit
5b62f8e3ee
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
};
|
||||
|
|
@ -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 }) {
|
||||
|
|
|
|||
|
|
@ -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)) : {};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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') }}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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, {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
mutation updateWorkItemNotificationsWidget($input: WorkItemUpdateInput!) {
|
||||
workItemUpdate(input: $input) {
|
||||
workItem {
|
||||
id
|
||||
widgets {
|
||||
... on WorkItemWidgetNotifications {
|
||||
type
|
||||
subscribed
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -36,4 +36,9 @@ fragment WorkItemMetadataWidgets on WorkItemWidget {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
... on WorkItemWidgetNotifications {
|
||||
type
|
||||
subscribed
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -85,4 +85,8 @@ fragment WorkItemWidgets on WorkItemWidget {
|
|||
... on WorkItemWidgetNotes {
|
||||
type
|
||||
}
|
||||
... on WorkItemWidgetNotifications {
|
||||
type
|
||||
subscribed
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
@import './pages/colors';
|
||||
@import './pages/commits';
|
||||
@import './pages/detail_page';
|
||||
@import './pages/events';
|
||||
@import './pages/groups';
|
||||
@import './pages/hierarchy';
|
||||
|
|
|
|||
|
|
@ -93,3 +93,14 @@
|
|||
top: -8px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
.work-item-notifications-form {
|
||||
.gl-toggle {
|
||||
@include gl-ml-auto;
|
||||
}
|
||||
|
||||
.gl-toggle-label {
|
||||
@include gl-font-weight-normal;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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?)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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) }
|
||||
|
|
|
|||
|
|
@ -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])
|
||||
|
|
|
|||
|
|
@ -29,3 +29,5 @@ module Branches
|
|||
end
|
||||
end
|
||||
end
|
||||
|
||||
Branches::ValidateNewService.prepend_mod
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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. |
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
```
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
---
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
```
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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 ""
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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 = [
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,
|
||||
})),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -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]]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
],
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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' }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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: [
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
module ContentEditorHelpers
|
||||
def switch_to_content_editor
|
||||
click_button _('Viewing markdown')
|
||||
click_button _('Editing markdown')
|
||||
click_button _('Rich text')
|
||||
end
|
||||
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Reference in New Issue