Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-05-01 09:11:12 +00:00
parent df4287ff13
commit f52c46075c
115 changed files with 1609 additions and 395 deletions

View File

@ -422,6 +422,20 @@ update-patch:
- !reference [.rules:test:omnibus-base, rules]
- !reference [.rules:test:update-patch, rules]
# Job tests upgrade from previous internal release to latest if available
# Requires GITLAB_QA_DEV_ACCESS_TOKEN to pull internal release
update-patch-from-internal-to-internal:
extends:
- .omnibus-e2e
- .update-script
variables:
UPDATE_TYPE: internal_patch
QA_RSPEC_TAGS: --tag health_check
rules:
- !reference [.rules:test:update-jobs-never, rules]
- !reference [.rules:test:omnibus-base, rules]
- !reference [.rules:test:update-patch, rules]
update-from-patch-to-stable:
extends:
- .omnibus-e2e

View File

@ -5,6 +5,7 @@ import { buildApiUrl } from './api_utils';
const GROUP_PATH = '/api/:version/groups/:id';
const GROUPS_PATH = '/api/:version/groups.json';
const GROUP_MEMBERS_PATH = '/api/:version/groups/:id/members';
const GROUP_MEMBER_PATH = '/api/:version/groups/:id/members/:user_id';
const GROUP_ALL_MEMBERS_PATH = '/api/:version/groups/:id/members/all';
const DESCENDANT_GROUPS_PATH = '/api/:version/groups/:id/descendant_groups';
const GROUP_TRANSFER_LOCATIONS_PATH = 'api/:version/groups/:id/transfer_locations';
@ -72,6 +73,12 @@ export const getGroupMembers = (groupId, inherited = false, params = {}) => {
return axios.get(url, { params });
};
export const deleteGroupMember = (groupId, userId) => {
const url = buildApiUrl(GROUP_MEMBER_PATH).replace(':id', groupId).replace(':user_id', userId);
return axios.delete(url);
};
export const createGroup = (params) => {
const url = buildApiUrl(GROUPS_PATH);
return axios.post(url, params);

View File

@ -22,6 +22,11 @@ export default {
default: null,
required: false,
},
size: {
type: String,
default: 'medium',
required: false,
},
},
emits: ['toggledPaused', 'deleted'],
computed: {
@ -45,13 +50,21 @@ export default {
<template>
<gl-button-group>
<runner-edit-button v-if="canUpdate && editUrl" :href="editUrl" />
<slot><!-- space for other actions --></slot>
<runner-edit-button v-if="canUpdate && editUrl" :size="size" :href="editUrl" />
<runner-pause-button
v-if="canUpdate"
:runner="runner"
:compact="true"
:size="size"
@toggledPaused="onToggledPaused"
/>
<runner-delete-button v-if="canDelete" :runner="runner" :compact="true" @deleted="onDeleted" />
<runner-delete-button
v-if="canDelete"
:runner="runner"
:compact="true"
:size="size"
@deleted="onDeleted"
/>
</gl-button-group>
</template>

View File

@ -22,6 +22,11 @@ export default {
required: false,
default: false,
},
size: {
type: String,
default: 'medium',
required: false,
},
},
emits: ['deleted'],
computed: {
@ -73,6 +78,7 @@ export default {
v-gl-tooltip="loading ? '' : tooltip"
:aria-label="ariaLabel"
:icon="loading ? '' : icon"
:size="size"
:class="buttonClass"
:loading="loading"
variant="danger"

View File

@ -15,6 +15,11 @@ export default {
required: false,
default: null,
},
size: {
type: String,
default: 'medium',
required: false,
},
},
I18N_EDIT,
};
@ -26,6 +31,7 @@ export default {
v-gl-tooltip="$options.I18N_EDIT"
:aria-label="$options.I18N_EDIT"
:href="href"
:size="size"
icon="pencil"
v-on="$listeners"
/>

View File

@ -50,6 +50,11 @@ export default {
required: false,
default: false,
},
fixed: {
type: Boolean,
required: false,
default: true,
},
loading: {
type: Boolean,
required: false,
@ -124,10 +129,10 @@ export default {
:items="runners"
:fields="fields"
:tbody-tr-attr="runnerTrAttr"
:fixed="fixed"
data-testid="runner-list"
stacked="md"
primary-key="id"
fixed
>
<template #head(checkbox)>
<runner-bulk-delete-checkbox :runners="runners" />

View File

@ -23,6 +23,11 @@ export default {
required: false,
default: false,
},
size: {
type: String,
default: 'medium',
required: false,
},
},
emits: ['toggledPaused'],
computed: {
@ -60,6 +65,7 @@ export default {
<gl-button
v-gl-tooltip="loading ? '' : tooltip"
:icon="icon"
:size="size"
:aria-label="ariaLabel"
:loading="loading"
@click="onClick"

View File

@ -53,11 +53,12 @@ export default {
text: __('Cancel'),
};
},
groupLeaveConfirmationMessage() {
groupLeaveConfirmationTitle() {
if (!this.targetGroup) {
return '';
}
return sprintf(s__('GroupsTree|Are you sure you want to leave the "%{fullName}" group?'), {
return sprintf(s__('GroupsTree|Are you sure you want to leave "%{fullName}"?'), {
fullName: this.targetGroup.fullName,
});
},
@ -240,13 +241,24 @@ export default {
<gl-modal
modal-id="leave-group-modal"
:visible="isModalVisible"
:title="__('Are you sure?')"
:title="groupLeaveConfirmationTitle"
:action-primary="primaryProps"
:action-cancel="cancelProps"
@primary="leaveGroup"
@hide="hideModal"
>
{{ groupLeaveConfirmationMessage }}
<p>{{ s__('GroupsTree|When you leave this group:') }}</p>
<ul>
<li>{{ s__('GroupsTree|You lose access to all projects within this group') }}</li>
<li>
{{
s__(
'GroupsTree|Your assigned issues and merge requests remain, but you cannot view or modify them',
)
}}
</li>
<li>{{ s__('GroupsTree|You need an invitation to rejoin') }}</li>
</ul>
</gl-modal>
</div>
</template>

View File

@ -14,6 +14,7 @@ export const formatGroup = (group) => ({
updatedAt: group.updated_at,
avatarUrl: group.avatar_url,
userPermissions: {
canLeave: group.can_leave,
removeGroup: group.can_remove,
viewEditPage: group.can_edit,
},

View File

@ -261,7 +261,6 @@ export default {
</ul>
<div class="gl-ml-auto gl-hidden gl-items-center lg:gl-flex">
<discussion-counter :blocks-merge="blocksMerge" hide-options />
<submit-review-button v-if="glFeatures.improvedReviewExperience" class="gl-mr-3" />
<div v-if="isSignedIn" :class="{ 'gl-flex gl-gap-3': isNotificationsTodosButtons }">
<todo-widget
:issuable-id="issuableId"
@ -276,6 +275,7 @@ export default {
issuable-type="merge_request"
/>
</div>
<submit-review-button v-if="glFeatures.improvedReviewExperience" class="gl-ml-3" />
</div>
</div>
</div>

View File

@ -16,6 +16,7 @@ fragment Group on Group {
createdAt
updatedAt
userPermissions {
canLeave
removeGroup
viewEditPage
}

View File

@ -1,5 +1,9 @@
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
import {
ACTION_EDIT,
ACTION_DELETE,
ACTION_LEAVE,
} from '~/vue_shared/components/list_actions/constants';
import {
TIMESTAMP_TYPE_CREATED_AT,
TIMESTAMP_TYPE_UPDATED_AT,
@ -14,6 +18,10 @@ const availableGroupActions = (userPermissions) => {
baseActions.push(ACTION_EDIT);
}
if (userPermissions.canLeave) {
baseActions.push(ACTION_LEAVE);
}
if (userPermissions.removeGroup) {
baseActions.push(ACTION_DELETE);
}

View File

@ -140,6 +140,7 @@ export default {
class="gl-h-full"
:title="badgeTitle"
:aria-label="badgeTitle"
tag="a"
>
{{ openMRsCountText }}
</gl-badge>

View File

@ -58,7 +58,7 @@ function mountSubmitReviewButton(pinia) {
el,
pinia,
render(h) {
return h(SubmitReviewButton, { attrs: { class: 'gl-mr-3' } });
return h(SubmitReviewButton, { attrs: { class: 'gl-ml-3' } });
},
});
}

View File

@ -19,8 +19,7 @@ import {
TODO_ACTION_TYPE_UNMERGEABLE,
TODO_ACTION_TYPE_SSH_KEY_EXPIRED,
TODO_ACTION_TYPE_SSH_KEY_EXPIRING_SOON,
TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED,
TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED,
DUO_ACCESS_GRANTED_ACTIONS,
} from '../constants';
export default {
@ -61,15 +60,11 @@ export default {
this.todo.action !== TODO_ACTION_TYPE_UNMERGEABLE &&
this.todo.action !== TODO_ACTION_TYPE_SSH_KEY_EXPIRED &&
this.todo.action !== TODO_ACTION_TYPE_SSH_KEY_EXPIRING_SOON &&
this.todo.action !== TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED &&
this.todo.action !== TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED
!DUO_ACCESS_GRANTED_ACTIONS.includes(this.todo.action)
);
},
showAvatarOnNote() {
return (
this.todo.action !== TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED &&
this.todo.action !== TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED
);
return !DUO_ACCESS_GRANTED_ACTIONS.includes(this.todo.action);
},
author() {
if (this.isHiddenBySaml) {
@ -162,13 +157,7 @@ export default {
name = s__('Todos|Your SSH key is expiring soon');
}
if (this.todo.action === TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED) {
name = s__(
'Todos|You now have access to AI-powered features. Learn how to set up Code Suggestions and Chat in your IDE',
);
}
if (this.todo.action === TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED) {
if (DUO_ACCESS_GRANTED_ACTIONS.includes(this.todo.action)) {
name = s__(
'Todos|You now have access to AI-powered features. Learn how to set up Code Suggestions and Chat in your IDE',
);

View File

@ -12,8 +12,7 @@ import {
TODO_TARGET_TYPE_MERGE_REQUEST,
TODO_TARGET_TYPE_PIPELINE,
TODO_TARGET_TYPE_SSH_KEY,
TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED,
TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED,
DUO_ACCESS_GRANTED_ACTIONS,
} from '../constants';
export default {
@ -44,10 +43,7 @@ export default {
return this.todo.action === TODO_ACTION_TYPE_MEMBER_ACCESS_REQUESTED;
},
isDuoActionType() {
return (
this.todo.action === TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED ||
this.todo.action === TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED
);
return DUO_ACCESS_GRANTED_ACTIONS.includes(this.todo.action);
},
issuableType() {
if (this.isMergeRequest) {

View File

@ -29,6 +29,13 @@ export const TODO_ACTION_TYPE_SSH_KEY_EXPIRED = 'ssh_key_expired';
export const TODO_ACTION_TYPE_SSH_KEY_EXPIRING_SOON = 'ssh_key_expiring_soon';
export const TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED = 'duo_pro_access_granted';
export const TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED = 'duo_enterprise_access_granted';
export const TODO_ACTION_TYPE_DUO_CORE_ACCESS_GRANTED = 'duo_core_access_granted';
export const DUO_ACCESS_GRANTED_ACTIONS = [
TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED,
TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED,
TODO_ACTION_TYPE_DUO_CORE_ACCESS_GRANTED,
];
export const TODO_EMPTY_TITLE_POOL = [
s__("Todos|Good job! Looks like you don't have anything left on your To-Do List"),

View File

@ -33,7 +33,7 @@ export default {
<token-permissions v-if="glFeatures.allowPushRepositoryForJobToken" />
<inbound-token-access />
<auth-log />
<outbound-token-access />
<outbound-token-access v-if="!glFeatures.removeLimitCiJobTokenScope" />
</template>
</gl-intersection-observer>
</template>

View File

@ -167,6 +167,9 @@ export const useAccessTokens = defineStore('accessTokens', {
message: s__('AccessTokens|The token was revoked successfully.'),
variant: 'success',
});
// Reset the token to avoid a situation where a token is created or rotated and then revoked,
// but the `Your token` banner still displays the token.
this.token = null;
// Reset pagination to avoid situations like: page 2 contains only one token and after it
// is revoked the page shows `No tokens access tokens` (but there are 20 tokens on page 1).
this.page = 1;

View File

@ -0,0 +1,104 @@
<script>
import { GlModal } from '@gitlab/ui';
import { sprintf } from '@gitlab/ui/dist/utils/i18n';
import { renderLeaveSuccessToast } from '~/vue_shared/components/groups_list/utils';
import { createAlert } from '~/alert';
import { s__, __ } from '~/locale';
import { deleteGroupMember } from '~/api/groups_api';
export default {
name: 'GroupListItemLeaveModal',
components: {
GlModal,
},
model: {
prop: 'visible',
event: 'change',
},
props: {
visible: {
type: Boolean,
required: false,
default: false,
},
modalId: {
type: String,
required: true,
},
group: {
type: Object,
required: true,
},
},
data() {
return {
isLoading: false,
};
},
modal: {
actionCancel: { text: __('Cancel') },
},
computed: {
title() {
return sprintf(s__('GroupsTree|Are you sure you want to leave "%{fullName}"?'), {
fullName: this.group.fullName,
});
},
actionPrimary() {
return {
text: s__('GroupsTree|Leave group'),
attributes: {
variant: 'danger',
loading: this.isLoading,
},
};
},
},
methods: {
async handlePrimary() {
this.isLoading = true;
try {
await deleteGroupMember(this.group.id, gon.current_user_id);
this.$emit('success');
renderLeaveSuccessToast(this.group);
} catch (error) {
createAlert({
message: s__(
'GroupsTree|An error occurred while leaving the group. Please refresh the page to try again.',
),
error,
captureError: true,
});
} finally {
this.isLoading = false;
}
},
},
};
</script>
<template>
<gl-modal
v-bind="$options.modal"
:modal-id="modalId"
:visible="visible"
:title="title"
:action-primary="actionPrimary"
@primary.prevent="handlePrimary"
@change="$emit('change')"
>
<p>{{ s__('GroupsTree|When you leave this group:') }}</p>
<ul>
<li>{{ s__('GroupsTree|You lose access to all projects within this group') }}</li>
<li>
{{
s__(
'GroupsTree|Your assigned issues and merge requests remain, but you cannot view or modify them',
)
}}
</li>
<li>{{ s__('GroupsTree|You need an invitation to rejoin') }}</li>
</ul>
</gl-modal>
</template>

View File

@ -9,7 +9,11 @@ import { VISIBILITY_TYPE_ICON, GROUP_VISIBILITY_TYPE } from '~/visibility_level/
import { ACCESS_LEVEL_LABELS, ACCESS_LEVEL_NO_ACCESS_INTEGER } from '~/access_level/constants';
import { __ } from '~/locale';
import { numberToMetricPrefix } from '~/lib/utils/number_utils';
import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
import {
ACTION_EDIT,
ACTION_DELETE,
ACTION_LEAVE,
} from '~/vue_shared/components/list_actions/constants';
import {
TIMESTAMP_TYPES,
TIMESTAMP_TYPE_CREATED_AT,
@ -17,6 +21,7 @@ import {
import ListItem from '~/vue_shared/components/resource_lists/list_item.vue';
import ListItemStat from '~/vue_shared/components/resource_lists/list_item_stat.vue';
import { renderDeleteSuccessToast, deleteParams } from '~/vue_shared/components/groups_list/utils';
import GroupListItemLeaveModal from '~/vue_shared/components/groups_list/group_list_item_leave_modal.vue';
import GroupListItemPreventDeleteModal from './group_list_item_prevent_delete_modal.vue';
import GroupListItemInactiveBadge from './group_list_item_inactive_badge.vue';
@ -34,6 +39,7 @@ export default {
ListItemStat,
GlIcon,
GlBadge,
GroupListItemLeaveModal,
GroupListItemPreventDeleteModal,
GroupListItemDeleteModal,
GroupListItemInactiveBadge,
@ -68,7 +74,8 @@ export default {
data() {
return {
isDeleteModalVisible: false,
isDeleteLoading: false,
isDeleteModalLoading: false,
isLeaveModalVisible: false,
modalId: uniqueId('groups-list-item-modal-id-'),
};
},
@ -111,34 +118,49 @@ export default {
[ACTION_DELETE]: {
action: this.onActionDelete,
},
[ACTION_LEAVE]: {
action: this.onActionLeave,
},
};
},
hasActionDelete() {
return this.group.availableActions?.includes(ACTION_DELETE);
},
hasActionLeave() {
return this.group.availableActions?.includes(ACTION_LEAVE);
},
hasFooterAction() {
return this.hasActionDelete || this.hasActionLeave;
},
},
methods: {
onActionDelete() {
this.isDeleteModalVisible = true;
},
onModalChange(isVisible) {
onDeleteModalChange(isVisible) {
this.isDeleteModalVisible = isVisible;
},
async onDeleteModalConfirm() {
this.isDeleteLoading = true;
this.isDeleteModalLoading = true;
try {
await axios.delete(`/${this.group.fullPath}`, {
params: deleteParams(this.group),
});
this.$emit('refetch');
this.refetch();
renderDeleteSuccessToast(this.group);
} catch (error) {
createAlert({ message: this.$options.i18n.deleteErrorMessage, error, captureError: true });
} finally {
this.isDeleteLoading = false;
this.isDeleteModalLoading = false;
}
},
onActionLeave() {
this.isLeaveModalVisible = true;
},
refetch() {
this.$emit('refetch');
},
},
};
</script>
@ -189,23 +211,34 @@ export default {
/>
</template>
<template v-if="hasActionDelete" #footer>
<group-list-item-prevent-delete-modal
v-if="group.isLinkedToSubscription"
:visible="isDeleteModalVisible"
:modal-id="modalId"
@change="onModalChange"
/>
<group-list-item-delete-modal
v-else
:visible="isDeleteModalVisible"
:modal-id="modalId"
:phrase="group.fullName"
:confirm-loading="isDeleteLoading"
:group="group"
@confirm.prevent="onDeleteModalConfirm"
@change="onModalChange"
/>
<template v-if="hasFooterAction" #footer>
<template v-if="hasActionDelete">
<group-list-item-prevent-delete-modal
v-if="group.isLinkedToSubscription"
:visible="isDeleteModalVisible"
:modal-id="modalId"
@change="onDeleteModalChange"
/>
<group-list-item-delete-modal
v-else
:visible="isDeleteModalVisible"
:modal-id="modalId"
:phrase="group.fullName"
:confirm-loading="isDeleteModalLoading"
:group="group"
@confirm.prevent="onDeleteModalConfirm"
@change="onDeleteModalChange"
/>
</template>
<template v-if="hasActionLeave">
<group-list-item-leave-modal
v-model="isLeaveModalVisible"
:modal-id="modalId"
:group="group"
@success="refetch"
/>
</template>
</template>
<template #children>

View File

@ -21,6 +21,14 @@ export const renderDeleteSuccessToast = (item) => {
);
};
export const renderLeaveSuccessToast = (group) => {
toast(
sprintf(__("Left the '%{group_name}' group successfully."), {
group_name: group.fullName,
}),
);
};
export const deleteParams = (item) => {
// If delayed deletion is disabled or the project/group is not yet marked for deletion
if (!item.isAdjournedDeletionEnabled || !item.markedForDeletionOn) {

View File

@ -1,6 +1,7 @@
import { __ } from '~/locale';
export const ACTION_EDIT = 'edit';
export const ACTION_LEAVE = 'leave';
export const ACTION_RESTORE = 'restore';
export const ACTION_DELETE = 'delete';
@ -13,9 +14,14 @@ export const BASE_ACTIONS = {
text: __('Restore'),
order: 2,
},
[ACTION_DELETE]: {
text: __('Delete'),
[ACTION_LEAVE]: {
text: __('Leave group'),
variant: 'danger',
order: 3,
},
[ACTION_DELETE]: {
text: __('Delete'),
variant: 'danger',
order: 4,
},
};

View File

@ -50,6 +50,7 @@ export default {
:timestamp-type="timestampType"
:initial-expanded="initialExpanded"
@load-children="$emit('load-children', $event)"
@refetch="$emit('refetch')"
/>
</ul>
</template>

View File

@ -98,12 +98,15 @@ export default {
this.$emit('load-children', this.item.id);
}
},
onRefetch() {
this.$emit('refetch');
},
},
};
</script>
<template>
<component :is="itemComponent" v-bind="itemProps">
<component :is="itemComponent" v-bind="itemProps" @refetch="onRefetch">
<template v-if="item.hasChildren" #children-toggle>
<gl-button v-bind="expandButtonProps" @click="onNestedItemsToggleClick" />
</template>
@ -116,6 +119,7 @@ export default {
:initial-expanded="initialExpanded"
:class="nestedItemsContainerClasses"
@load-children="$emit('load-children', $event)"
@refetch="onRefetch"
/>
</template>
</component>

View File

@ -35,7 +35,7 @@ import {
I18N_WORK_ITEM_ERROR_CREATING,
sprintfWorkItem,
i18n,
NAME_TO_LOWERCASE_TEXT_MAP,
NAME_TO_TEXT_LOWERCASE_MAP,
NAME_TO_TEXT_MAP,
WIDGET_TYPE_ASSIGNEES,
WIDGET_TYPE_COLOR,
@ -314,7 +314,7 @@ export default {
return getDisplayReference(this.selectedProjectFullPath, this.relatedItem.reference);
},
relatedItemType() {
return NAME_TO_LOWERCASE_TEXT_MAP[this.relatedItem?.type];
return NAME_TO_TEXT_LOWERCASE_MAP[this.relatedItem?.type];
},
workItemAssignees() {
return findWidget(WIDGET_TYPE_ASSIGNEES, this.workItem);
@ -489,7 +489,7 @@ export default {
? this.$options.i18n.resolveOneThreadText
: this.$options.i18n.resolveAllThreadsText;
return sprintf(warning, {
workItemType: this.selectedWorkItemTypeName,
workItemType: NAME_TO_TEXT_LOWERCASE_MAP[this.selectedWorkItemTypeName],
});
},
isFormFilled() {

View File

@ -1,7 +1,7 @@
<script>
import { GlButton, GlModal } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
import { NAME_TO_LOWERCASE_TEXT_MAP } from '../constants';
import { NAME_TO_TEXT_LOWERCASE_MAP } from '../constants';
export default {
components: {
@ -22,7 +22,7 @@ export default {
cancelConfirmationText() {
return sprintf(
s__('WorkItem|Are you sure you want to cancel creating this %{workItemType}?'),
{ workItemType: NAME_TO_LOWERCASE_TEXT_MAP[this.workItemType] },
{ workItemType: NAME_TO_TEXT_LOWERCASE_MAP[this.workItemType] },
);
},
},

View File

@ -5,7 +5,7 @@ import { __, s__ } from '~/locale';
import { isMetaClick } from '~/lib/utils/common_utils';
import { newWorkItemPath } from '~/work_items/utils';
import {
NAME_TO_LOWERCASE_TEXT_MAP,
NAME_TO_TEXT_LOWERCASE_MAP,
sprintfWorkItem,
ROUTES,
RELATED_ITEM_ID_URL_QUERY_PARAM,
@ -136,7 +136,7 @@ export default {
});
},
selectedWorkItemTypeLowercase() {
return NAME_TO_LOWERCASE_TEXT_MAP[this.selectedWorkItemTypeName];
return NAME_TO_TEXT_LOWERCASE_MAP[this.selectedWorkItemTypeName];
},
newWorkItemButtonText() {
return this.alwaysShowWorkItemTypeSelect && this.selectedWorkItemTypeName

View File

@ -93,6 +93,16 @@ export default {
required: false,
default: false,
},
canAddDesign: {
type: Boolean,
required: false,
default: false,
},
canUpdateDesign: {
type: Boolean,
required: false,
default: false,
},
},
apollo: {
designCollection: {
@ -411,7 +421,7 @@ export default {
<template #actions>
<gl-button
v-if="isLatestVersion"
v-if="isLatestVersion && canUpdateDesign"
category="tertiary"
size="small"
variant="link"
@ -423,7 +433,7 @@ export default {
{{ selectAllButtonText }}
</gl-button>
<archive-design-button
v-if="isLatestVersion"
v-if="isLatestVersion && canUpdateDesign"
data-testid="archive-button"
button-class="work-item-design-hidden-xs work-item-design-show-sm"
:has-selected-designs="hasSelectedDesigns"
@ -433,7 +443,7 @@ export default {
{{ $options.i18n.archiveDesignText }}
</archive-design-button>
<archive-design-button
v-if="isLatestVersion"
v-if="isLatestVersion && canUpdateDesign"
v-gl-tooltip.bottom
data-testid="archive-button"
button-class="work-item-design-hidden-sm"
@ -445,6 +455,7 @@ export default {
@archive-selected-designs="onArchiveDesign"
/>
<gl-button
v-if="canAddDesign"
size="small"
data-testid="add-design"
:disabled="isSaving"
@ -472,7 +483,8 @@ export default {
>
{{ error || uploadError }}
</gl-alert>
<design-dropzone
<component
:is="canAddDesign ? 'design-dropzone' : 'div'"
show-upload-design-overlay
validate-design-upload-on-dragover
:accept-design-formats="$options.VALID_DESIGN_FILE_MIMETYPE.mimetype"
@ -514,7 +526,7 @@ export default {
/>
<gl-form-checkbox
v-if="isLatestVersion"
v-if="isLatestVersion && canUpdateDesign"
:id="`design-checkbox-${design.id}`"
:name="design.filename"
:checked="isDesignSelected(design.filename)"
@ -526,7 +538,7 @@ export default {
/>
</li>
</vue-draggable>
</design-dropzone>
</component>
</template>
</crud-component>
</div>

View File

@ -111,11 +111,15 @@ export default {
},
update(data) {
const { event, image, imageV432x230 } = data.designManagement.designAtVersion;
const {
userPermissions: { updateDesign },
} = data.designManagement.designAtVersion.design.issue;
return {
...data.designManagement.designAtVersion.design,
event,
image,
imageV432x230,
canUpdateDesign: updateDesign,
};
},
result({ data }) {
@ -211,6 +215,9 @@ export default {
isAnnotating() {
return Boolean(this.annotationCoordinates);
},
canUpdateDesign() {
return this.design.canUpdateDesign || false;
},
},
watch: {
resolvedDiscussions(val) {
@ -366,6 +373,7 @@ export default {
:is-latest-version="isLatestVersion"
:all-designs="allDesigns"
:current-user-design-todos="currentUserDesignTodos"
:can-update-design="canUpdateDesign"
@toggle-sidebar="toggleSidebar"
@archive-design="onArchiveDesign"
@todosUpdated="updateWorkItemDesignCurrentTodosWidgetCache"

View File

@ -61,6 +61,10 @@ export default {
required: false,
default: () => [],
},
canUpdateDesign: {
type: Boolean,
required: false,
},
},
computed: {
toggleCommentsButtonLabel() {
@ -116,7 +120,7 @@ export default {
:aria-label="$options.i18n.downloadButtonLabel"
/>
<archive-design-button
v-if="isLatestVersion"
v-if="isLatestVersion && canUpdateDesign"
v-gl-tooltip.bottom
button-size="medium"
:title="$options.i18n.archiveButtonLabel"

View File

@ -10,6 +10,7 @@ import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { __, sprintf } from '~/locale';
import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import ReplyButton from '~/notes/components/note_actions/reply_button.vue';
import { NAME_TO_TEXT_LOWERCASE_MAP } from '../../constants';
import { getMutation, optimisticAwardUpdate, getNewCustomEmojiPath } from '../../notes/award_utils';
export default {
@ -135,7 +136,7 @@ export default {
},
displayAuthorBadgeText() {
return sprintf(__('This user is the author of this %{workItemType}.'), {
workItemType: this.workItemType.toLowerCase(),
workItemType: NAME_TO_TEXT_LOWERCASE_MAP[this.workItemType],
});
},
displayMemberBadgeText() {

View File

@ -6,6 +6,7 @@ import { __, s__, sprintf } from '~/locale';
import { findDesignsWidget, getParentGroupName, isMilestoneWidget } from '~/work_items/utils';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
import {
NAME_TO_TEXT_LOWERCASE_MAP,
NAME_TO_TEXT_MAP,
ALLOWED_CONVERSION_TYPES,
sprintfWorkItem,
@ -375,7 +376,7 @@ export default {
),
{
workItemType: NAME_TO_TEXT_MAP[this.selectedWorkItemType.name],
childItemType: this.allowedChildTypes?.[0]?.name?.toLocaleLowerCase(),
childItemType: NAME_TO_TEXT_LOWERCASE_MAP[this.allowedChildTypes?.[0]?.name],
},
);

View File

@ -92,6 +92,7 @@ import WorkItemCreateBranchMergeRequestSplitButton from './work_item_development
const defaultWorkspacePermissions = {
createDesign: false,
updateDesign: false,
moveDesign: false,
};
@ -396,7 +397,7 @@ export default {
return this.findWidget(WIDGET_TYPE_DESIGNS) && (this.$router || this.isBoard);
},
showUploadDesign() {
return this.hasDesignWidget && this.workspacePermissions.createDesign;
return this.hasDesignWidget && this.canAddDesign;
},
canReorderDesign() {
return this.hasDesignWidget && this.workspacePermissions.moveDesign;
@ -590,6 +591,12 @@ export default {
const rootPath = this.workItem?.namespace?.webUrl || '';
return this.isGroupWorkItem ? `${rootPath}/-/uploads` : `${rootPath}/uploads`;
},
canAddDesign() {
return this.workspacePermissions.createDesign;
},
canUpdateDesign() {
return this.workspacePermissions.updateDesign;
},
},
mounted() {
addShortcutsExtension(ShortcutsWorkItems);
@ -733,7 +740,7 @@ export default {
this.editMode = false;
},
isValidDesignUpload(files) {
if (!this.workspacePermissions.createDesign) return false;
if (!this.canAddDesign) return false;
if (files.length > MAXIMUM_FILE_UPLOAD_LIMIT) {
this.designUploadError = MAXIMUM_FILE_UPLOAD_LIMIT_REACHED;
@ -1166,6 +1173,8 @@ export default {
:is-saving="isSaving"
:can-reorder-design="canReorderDesign"
:is-board="isBoard"
:can-add-design="canAddDesign"
:can-update-design="canUpdateDesign"
@upload="onUploadDesign"
@dismissError="designUploadError = null"
>

View File

@ -16,6 +16,7 @@ import {
WIDGET_TYPE_MILESTONE,
WIDGET_TYPE_ITERATION,
WORK_ITEM_TYPE_NAME_TASK,
NAME_TO_TEXT_LOWERCASE_MAP,
NAME_TO_TEXT_MAP,
} from '../../constants';
import WorkItemProjectsListbox from './work_item_projects_listbox.vue';
@ -263,8 +264,8 @@ export default {
),
{
invalidWorkItemsList: this.invalidWorkItemsToAdd.map(({ title }) => title).join(', '),
childWorkItemType: this.childrenTypeText,
parentWorkItemType: this.parentWorkItemType,
childWorkItemType: NAME_TO_TEXT_LOWERCASE_MAP[this.childrenTypeText],
parentWorkItemType: NAME_TO_TEXT_LOWERCASE_MAP[this.parentWorkItemType],
},
);
},

View File

@ -14,7 +14,7 @@ import {
WIDGET_TYPE_HIERARCHY,
INJECTION_LINK_CHILD_PREVENT_ROUTER_NAVIGATION,
DETAIL_VIEW_QUERY_PARAM_NAME,
NAME_TO_LOWERCASE_TEXT_MAP,
NAME_TO_TEXT_LOWERCASE_MAP,
NAME_TO_TEXT_MAP,
} from '../../constants';
import {
@ -308,7 +308,7 @@ export default {
},
methods: {
genericActionItems(workItemType) {
const workItemName = NAME_TO_LOWERCASE_TEXT_MAP[workItemType];
const workItemName = NAME_TO_TEXT_LOWERCASE_MAP[workItemType];
return [
{
title: sprintf(s__('WorkItem|New %{workItemName}'), { workItemName }),

View File

@ -313,7 +313,7 @@ export const NAME_TO_TEXT_MAP = {
[WORK_ITEM_TYPE_NAME_TICKET]: s__('WorkItem|Ticket'),
};
export const NAME_TO_LOWERCASE_TEXT_MAP = {
export const NAME_TO_TEXT_LOWERCASE_MAP = {
[WORK_ITEM_TYPE_NAME_EPIC]: s__('WorkItem|epic'),
[WORK_ITEM_TYPE_NAME_INCIDENT]: s__('WorkItem|incident'),
[WORK_ITEM_TYPE_NAME_ISSUE]: s__('WorkItem|issue'),

View File

@ -3,6 +3,7 @@ query workspacePermissions($fullPath: ID!) {
id
userPermissions {
createDesign
updateDesign
moveDesign
}
}

View File

@ -65,7 +65,7 @@ class Projects::RunnersController < Projects::ApplicationController
protected
def runner
@runner ||= project.runners.find(params[:id])
@runner ||= Ci::Runner.find(safe_params[:id])
end
def runner_params

View File

@ -18,6 +18,7 @@ module Projects
before_action do
push_frontend_feature_flag(:ci_variables_pages, current_user)
push_frontend_feature_flag(:allow_push_repository_for_job_token, @project)
push_frontend_feature_flag(:remove_limit_ci_job_token_scope, @project)
push_frontend_ability(ability: :admin_project, resource: @project, user: current_user)
push_frontend_ability(ability: :admin_protected_environments, resource: @project, user: current_user)
end

View File

@ -2,6 +2,7 @@
module Resolvers
class BoardListIssuesResolver < BaseResolver
prepend ::Issues::LookAheadPreloads
include BoardItemFilterable
argument :filters, Types::Boards::BoardIssueInputType,
@ -12,14 +13,14 @@ module Resolvers
alias_method :list, :object
def resolve(**args)
def resolve_with_lookahead(**args)
filters = item_filters(args[:filters])
mutually_exclusive_milestone_args!(filters)
filter_params = filters.merge(board_id: list.board.id, id: list.id)
service = ::Boards::Issues::ListService.new(list.board.resource_parent, context[:current_user], filter_params)
service.execute
apply_lookahead(service.execute)
end
# https://gitlab.com/gitlab-org/gitlab/-/issues/235681

View File

@ -23,7 +23,9 @@ module Types
field :direction,
GraphQL::Types::String,
null: true,
description: 'Direction of access. Defaults to INBOUND.'
description: 'Direction of access. Defaults to INBOUND.',
deprecated: { reason: 'Outbound job token scope is being removed. This field can only be INBOUND',
milestone: '18.0' }
field :default_permissions,
GraphQL::Types::Boolean,

View File

@ -22,7 +22,9 @@ module Types
Types::ProjectType.connection_type,
null: false,
description: "Allow list of projects that are accessible using the current project's CI Job tokens.",
method: :outbound_projects
method: :outbound_projects,
deprecated: { reason: 'Outbound job token scope is being removed. Only inbound allowlist is supported',
milestone: '18.0' }
field :inbound_allowlist,
Types::Ci::JobTokenAccessibleProjectType.connection_type,

View File

@ -19,5 +19,6 @@ module Types
value 'ssh_key_expiring_soon', value: 15, description: 'SSH key of the user will expire soon.'
value 'duo_pro_access_granted', value: 16, description: 'Access to Duo Pro has been granted to the user.'
value 'duo_enterprise_access_granted', value: 17, description: 'Access to Duo Enterprise has been granted to the user.'
value 'duo_core_access_granted', value: 18, description: 'Access to Duo Core has been granted to the user.'
end
end

View File

@ -87,6 +87,8 @@ module Ci
private
def outbound_accessible?(accessed_project)
return true if Feature.enabled?(:remove_limit_ci_job_token_scope, current_project)
# if the setting is disabled any project is considered to be in scope.
return true unless current_project.ci_outbound_job_token_scope_enabled?

View File

@ -26,8 +26,9 @@ class Todo < ApplicationRecord
ADDED_APPROVER = 13 # This is an EE-only feature,
SSH_KEY_EXPIRED = 14
SSH_KEY_EXPIRING_SOON = 15
DUO_PRO_ACCESS_GRANTED = 16 # This is an EE-only feature,
DUO_ENTERPRISE_ACCESS_GRANTED = 17 # This is an EE-only feature,
DUO_PRO_ACCESS_GRANTED = 16 # This is an EE-only feature
DUO_ENTERPRISE_ACCESS_GRANTED = 17 # This is an EE-only feature
DUO_CORE_ACCESS_GRANTED = 18 # This is an EE-only feature
ACTION_NAMES = {
ASSIGNED => :assigned,
@ -46,7 +47,8 @@ class Todo < ApplicationRecord
SSH_KEY_EXPIRED => :ssh_key_expired,
SSH_KEY_EXPIRING_SOON => :ssh_key_expiring_soon,
DUO_PRO_ACCESS_GRANTED => :duo_pro_access_granted,
DUO_ENTERPRISE_ACCESS_GRANTED => :duo_enterprise_access_granted
DUO_ENTERPRISE_ACCESS_GRANTED => :duo_enterprise_access_granted,
DUO_CORE_ACCESS_GRANTED => :duo_core_access_granted
}.freeze
ACTIONS_MULTIPLE_ALLOWED = [Todo::MENTIONED, Todo::DIRECTLY_ADDRESSED, Todo::MEMBER_ACCESS_REQUESTED].freeze
@ -54,6 +56,7 @@ class Todo < ApplicationRecord
PARENTLESS_ACTION_TYPES = [
DUO_PRO_ACCESS_GRANTED,
DUO_ENTERPRISE_ACCESS_GRANTED,
DUO_CORE_ACCESS_GRANTED,
SSH_KEY_EXPIRED,
SSH_KEY_EXPIRING_SOON
].freeze
@ -360,7 +363,7 @@ class Todo < ApplicationRecord
end
def for_duo_access_granted?
action == DUO_PRO_ACCESS_GRANTED || action == DUO_ENTERPRISE_ACCESS_GRANTED
[DUO_PRO_ACCESS_GRANTED, DUO_ENTERPRISE_ACCESS_GRANTED, DUO_CORE_ACCESS_GRANTED].include?(action)
end
def parentless_type?

View File

@ -338,12 +338,13 @@ class TodoService
).distinct_user_ids
end
def bulk_insert_todos(users, attributes)
def bulk_insert_todos(users, attributes, &attribute_merger)
todos_ids = []
attribute_merger ||= ->(user, attrs) { attrs.merge(user_id: user.id) }
users.each_slice(BATCH_SIZE) do |users_batch|
todos_attributes = users_batch.map do |user|
Todo.new(attributes.merge(user_id: user.id)).attributes.except('id', 'created_at', 'updated_at')
Todo.new(attribute_merger.call(user, attributes)).attributes.except('id', 'created_at', 'updated_at')
end
todos_ids += Todo.insert_all(todos_attributes, returning: :id).rows.flatten unless todos_attributes.blank?

View File

@ -51,12 +51,12 @@
= gl_badge_tag tab_count_display(@merge_request, @diffs_count), { class: 'js-changes-tab-count', data: { gid: @merge_request.to_gid.to_s } }
.gl-flex.gl-flex-wrap.gl-items-center.justify-content-lg-end
#js-vue-discussion-counter{ data: { blocks_merge: @project.only_allow_merge_if_all_discussions_are_resolved?.to_s } }
#js-submit-review-button
- if !!@issuable_sidebar.dig(:current_user, :id)
.gl-flex.gl-gap-3
.js-sidebar-todo-widget-root{ data: { project_path: @issuable_sidebar[:project_full_path], iid: @issuable_sidebar[:iid], id: @issuable_sidebar[:id] } }
- if notifications_todos_buttons_enabled?
.js-sidebar-subscriptions-widget-root{ data: { full_path: @issuable_sidebar[:project_full_path], iid: @issuable_sidebar[:iid] } }
#js-submit-review-button
.gl-ml-auto.gl-items-center.gl-hidden.sm:gl-flex.lg:gl-hidden.gl-ml-3.js-expand-sidebar.gl-absolute.gl-right-5
= render Pajamas::ButtonComponent.new(icon: 'chevron-double-lg-left',

View File

@ -1076,6 +1076,16 @@
:idempotent: false
:tags: []
:queue_namespace: :cronjob
- :name: cronjob:scheduling_schedule_within
:worker_name: Gitlab::Scheduling::ScheduleWithinWorker
:feature_category: :scalability
:has_external_dependencies: false
:urgency: :low
:resource_boundary: :unknown
:weight: 1
:idempotent: true
:tags: []
:queue_namespace: :cronjob
- :name: cronjob:service_desk_custom_email_verification_cleanup
:worker_name: ServiceDesk::CustomEmailVerificationCleanupWorker
:feature_category: :service_desk

View File

@ -0,0 +1,69 @@
# frozen_string_literal: true
module Gitlab
module Scheduling
# Schedules another worker within the given time range using random splay.
#
# worker_class:
# The class name of the worker to be scheduled. REQUIRED.
# within_minutes (1..59):
# The maximum number of minutes to wait before scheduling the worker. OPTIONAL.
# within_hours (1..23):
# The maximum number of hours to wait before scheduling the worker. OPTIONAL.
#
# Example:
# # schedules MyWorker to run at a random time within the next 2 hours and 30 minutes
# Gitlab::Scheduling::ScheduleWithinWorker.perform_async({
# 'worker_class' => 'MyWorker',
# 'within_hours' => 2,
# 'within_minutes' => 30
# })
class ScheduleWithinWorker
include ApplicationWorker
include CronjobQueue # rubocop:disable Scalability/CronWorkerContext -- no relevant metadata
feature_category :scalability
data_consistency :sticky
idempotent!
def perform(args = {})
validate_arguments!(args)
worker_class = args['worker_class']&.constantize
within_minutes = args['within_minutes'].to_i
within_hours = args['within_hours'].to_i
random_minute = within_minutes > 0 ? Random.rand(within_minutes + 1) : 0
random_hour = within_hours > 0 ? Random.rand(within_hours + 1) : 0
scheduled_for = (random_hour.hours + random_minute.minutes).from_now
worker_class.perform_at(scheduled_for)
log_hash_metadata_on_done({
worker_class: worker_class.to_s,
within_minutes: within_minutes,
within_hours: within_hours,
selected_minute: random_minute,
selected_hour: random_hour,
scheduled_for: scheduled_for
})
end
private
def validate_arguments!(args)
raise ArgumentError, "worker_class is a required argument" unless args['worker_class']
raise ArgumentError, "within_minutes must be nil or in [1..59]" unless valid_minute?(args['within_minutes'])
raise ArgumentError, "within_hours must be nil or in [1..23]" unless valid_hour?(args['within_hours'])
end
def valid_minute?(minute)
minute.nil? || (1..59).cover?(minute.to_i)
end
def valid_hour?(hour)
hour.nil? || (1..23).cover?(hour.to_i)
end
end
end
end

View File

@ -0,0 +1,10 @@
---
name: duo_code_review_system_note
description:
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/536723
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/189025
rollout_issue_url:
milestone: '18.0'
group: group::code review
type: gitlab_com_derisk
default_enabled: false

View File

@ -0,0 +1,10 @@
---
name: remove_limit_ci_job_token_scope
description:
feature_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/383084
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/18902
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/537186
milestone: '18.0'
group: group::pipeline security
type: gitlab_com_derisk
default_enabled: false

View File

@ -749,8 +749,13 @@ Settings.cron_jobs['ci_schedule_old_pipelines_removal_cron_worker'] ||= {}
Settings.cron_jobs['ci_schedule_old_pipelines_removal_cron_worker']['cron'] ||= '*/11 * * * *'
Settings.cron_jobs['ci_schedule_old_pipelines_removal_cron_worker']['job_class'] = 'Ci::ScheduleOldPipelinesRemovalCronWorker'
Settings.cron_jobs['version_version_check_cron'] ||= {}
Settings.cron_jobs['version_version_check_cron']['cron'] ||= "#{rand(60)} #{rand(24)} * * *" # rubocop: disable Scalability/RandomCronSchedule -- https://gitlab.com/gitlab-org/gitlab/-/issues/536393
Settings.cron_jobs['version_version_check_cron']['job_class'] = 'Gitlab::Version::VersionCheckCronWorker'
Settings.cron_jobs['version_version_check_cron']['cron'] ||= "0 0 * * * UTC"
Settings.cron_jobs['version_version_check_cron']['job_class'] = 'Gitlab::Scheduling::ScheduleWithinWorker'
Settings.cron_jobs['version_version_check_cron']['args'] = {
'worker_class' => 'Gitlab::Version::VersionCheckCronWorker',
'within_minutes' => 59,
'within_hours' => 23
}
Gitlab.ee do
Settings.cron_jobs['analytics_devops_adoption_create_all_snapshots_worker'] ||= {}

View File

@ -443,6 +443,8 @@
- 1
- - gitlab_subscriptions_refresh_seats
- 1
- - gitlab_subscriptions_self_managed_duo_core_todo_notification
- 1
- - gitlab_subscriptions_trials_apply_trial
- 1
- - google_cloud_create_cloudsql_instance

View File

@ -5,4 +5,4 @@ feature_category: geo_replication
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/169242
milestone: '17.7'
queued_migration_version: 20241125133627
finalized_by: # version of the migration that finalized this BBM
finalized_by: '20250408231712'

View File

@ -6,4 +6,4 @@ introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/167609
milestone: '17.5'
queued_migration_version: 20240930122236
finalize_after: '2024-10-22'
finalized_by: # version of the migration that finalized this BBM
finalized_by: '20250320231523'

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class FinalizeHkBackfillPackagesRpmMetadataProjectId < Gitlab::Database::Migration[2.2]
milestone '17.11'
disable_ddl_transaction!
restrict_gitlab_migration gitlab_schema: :gitlab_main_cell
def up
ensure_batched_background_migration_is_finished(
job_class_name: 'BackfillPackagesRpmMetadataProjectId',
table_name: :packages_rpm_metadata,
column_name: :package_id,
job_arguments: [:project_id, :packages_packages, :project_id, :package_id],
finalize: true
)
end
def down; end
end

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
class FinalizeHkBackfillGroupWikiRepositoryStatesGroupId < Gitlab::Database::Migration[2.2]
milestone '18.0'
disable_ddl_transaction!
restrict_gitlab_migration gitlab_schema: :gitlab_main_cell
def up
ensure_batched_background_migration_is_finished(
job_class_name: 'BackfillGroupWikiRepositoryStatesGroupId',
table_name: :group_wiki_repository_states,
column_name: :id,
job_arguments: [:group_id, :group_wiki_repositories, :group_id, :group_wiki_repository_id],
finalize: true
)
end
def down; end
end

View File

@ -0,0 +1 @@
3adfb6da202db516de1aa1b2b7f9c2f4ab09dc5a33dbacd97afc8bf47980bf00

View File

@ -0,0 +1 @@
5253f5a26308e579a5cf97937bb449f4cfcce4d95157ee064d68585d7012b056

View File

@ -23004,7 +23004,7 @@ Represents an allowlist entry for the CI_JOB_TOKEN.
| <a id="cijobtokenscopeallowlistentryautopopulated"></a>`autopopulated` | [`Boolean`](#boolean) | Indicates whether the entry is created by the autopopulation process. |
| <a id="cijobtokenscopeallowlistentrycreatedat"></a>`createdAt` | [`Time!`](#time) | When the entry was created. |
| <a id="cijobtokenscopeallowlistentrydefaultpermissions"></a>`defaultPermissions` | [`Boolean`](#boolean) | Indicates whether default permissions are enabled (true) or fine-grained permissions are enabled (false). |
| <a id="cijobtokenscopeallowlistentrydirection"></a>`direction` | [`String`](#string) | Direction of access. Defaults to INBOUND. |
| <a id="cijobtokenscopeallowlistentrydirection"></a>`direction` {{< icon name="warning-solid" >}} | [`String`](#string) | **Deprecated** in GitLab 18.0. Outbound job token scope is being removed. This field can only be INBOUND. |
| <a id="cijobtokenscopeallowlistentryjobtokenpolicies"></a>`jobTokenPolicies` {{< icon name="warning-solid" >}} | [`[CiJobTokenScopePolicies!]`](#cijobtokenscopepolicies) | **Introduced** in GitLab 17.5. **Status**: Experiment. List of policies for the entry. |
| <a id="cijobtokenscopeallowlistentrysourceproject"></a>`sourceProject` | [`Project!`](#project) | Project that owns the allowlist entry. |
| <a id="cijobtokenscopeallowlistentrytarget"></a>`target` | [`CiJobTokenScopeTarget`](#cijobtokenscopetarget) | Group or project allowed by the entry. |
@ -23021,7 +23021,7 @@ Represents an allowlist entry for the CI_JOB_TOKEN.
| <a id="cijobtokenscopetypeinboundallowlist"></a>`inboundAllowlist` | [`CiJobTokenAccessibleProjectConnection!`](#cijobtokenaccessibleprojectconnection) | Allowlist of projects that can access the current project by authenticating with a CI/CD job token. (see [Connections](#connections)) |
| <a id="cijobtokenscopetypeinboundallowlistautopopulatedids"></a>`inboundAllowlistAutopopulatedIds` | [`[ProjectID!]!`](#projectid) | List of IDs of projects which have been created by the autopopulation process. |
| <a id="cijobtokenscopetypeinboundallowlistcount"></a>`inboundAllowlistCount` | [`Int!`](#int) | Count of projects that can access the current project by authenticating with a CI/CD job token. The count does not include nested projects. |
| <a id="cijobtokenscopetypeoutboundallowlist"></a>`outboundAllowlist` | [`ProjectConnection!`](#projectconnection) | Allow list of projects that are accessible using the current project's CI Job tokens. (see [Connections](#connections)) |
| <a id="cijobtokenscopetypeoutboundallowlist"></a>`outboundAllowlist` {{< icon name="warning-solid" >}} | [`ProjectConnection!`](#projectconnection) | **Deprecated** in GitLab 18.0. Outbound job token scope is being removed. Only inbound allowlist is supported. |
| <a id="cijobtokenscopetypeprojects"></a>`projects` {{< icon name="warning-solid" >}} | [`ProjectConnection!`](#projectconnection) | **Deprecated** in GitLab 15.9. The `projects` attribute is being deprecated. Use `outbound_allowlist`. |
### `CiJobTrace`
@ -45470,6 +45470,7 @@ Values for sorting timelogs.
| <a id="todoactionenumassigned"></a>`assigned` | User was assigned. |
| <a id="todoactionenumbuild_failed"></a>`build_failed` | Build triggered by the user failed. |
| <a id="todoactionenumdirectly_addressed"></a>`directly_addressed` | User was directly addressed. |
| <a id="todoactionenumduo_core_access_granted"></a>`duo_core_access_granted` | Access to Duo Core has been granted to the user. |
| <a id="todoactionenumduo_enterprise_access_granted"></a>`duo_enterprise_access_granted` | Access to Duo Enterprise has been granted to the user. |
| <a id="todoactionenumduo_pro_access_granted"></a>`duo_pro_access_granted` | Access to Duo Pro has been granted to the user. |
| <a id="todoactionenummarked"></a>`marked` | User added a to-do item. |

View File

@ -412,11 +412,18 @@ Additionally, there are multiple valid methods for passing the job token in the
## Limit your project's job token access (deprecated)
{{< alert type="note" >}}
{{< history >}}
- Deprecated in GitLab 16.0.
- Removal process [started](https://gitlab.com/gitlab-org/gitlab/-/issues/537186) in GitLab 18.0 by putting this feature behind [a flag](../../administration/feature_flags.md) named `remove_limit_ci_job_token_scope`, disabled by default.
{{< /history >}}
{{< alert type="warning" >}}
The [**Limit access _from_ this project**](#configure-the-job-token-scope-deprecated)
setting is disabled by default for all new projects and is [scheduled for removal](https://gitlab.com/gitlab-org/gitlab/-/issues/383084)
in GitLab 17.0. Project maintainers or owners should configure the [**Limit access _to_ this project**](#add-a-group-or-project-to-the-job-token-allowlist)
in GitLab 18.0. Project maintainers or owners should configure the [**Limit access _to_ this project**](#add-a-group-or-project-to-the-job-token-allowlist)
setting instead.
{{< /alert >}}

View File

@ -17,25 +17,16 @@ when they contain the `Changelog` [Git trailer](https://git-scm.com/docs/git-int
When generating the changelog, author and merge request details are added
automatically.
The `Changelog` trailer accepts the following values:
- `added`: New feature
- `fixed`: Bug fix
- `changed`: Feature change
- `deprecated`: New deprecation
- `removed`: Feature removal
- `security`: Security fix
- `performance`: Performance improvement
- `other`: Other
For a list of trailers, see [Add a trailer to a Git commit](../user/project/changelogs.md#add-a-trailer-to-a-git-commit).
An example of a Git commit to include in the changelog is the following:
```plaintext
Update git vendor to gitlab
Update git vendor to GitLab
Now that we are using gitaly to compile git, the git version isn't known
from the manifest, instead we are getting the gitaly version. Update our
vendor field to be `gitlab` to avoid cve matching old versions.
Now that we are using Gitaly to compile Git, the Git version isn't known
from the manifest. Instead, we are getting the Gitaly version. Update our
vendor field to be `gitlab` to avoid CVE matching old versions.
Changelog: changed
```

View File

@ -5,7 +5,7 @@ info: Any user with at least the Maintainer role can merge updates to this conte
title: Diagrams.net integration
---
In [wikis](../../user/markdown.md#diagramsnet-editor) you can use the diagrams.net editor to
In [wikis](../../user/project/wiki/markdown.md#diagramsnet-editor) you can use the diagrams.net editor to
create diagrams. The diagrams.net editor runs as a separate web service outside the GitLab
application and GitLab instance administrators can
[configure the URL](../../administration/integration/diagrams_net.md) that points to this service.

View File

@ -29,7 +29,7 @@ We only use Gollum as a storage abstraction layer, to handle the mapping between
When rendering wiki pages, we don't use Gollum at all and instead go through a
[custom Banzai pipeline](https://gitlab.com/gitlab-org/gitlab/-/blob/master/lib/banzai/pipeline/wiki_pipeline.rb).
This adds some [wiki-specific markup](../user/markdown.md#wiki-specific-markdown), such as the Gollum `[[link]]` syntax.
This adds some [wiki-specific markup](../user/project/wiki/markdown.md), such as the Gollum `[[link]]` syntax.
Because we do not make use of most of the Gollum features, we plan to move away from it entirely at some point.
[See this epic](https://gitlab.com/groups/gitlab-org/-/epics/2381) for reference.

View File

@ -44,6 +44,24 @@ To stage and commit your changes:
The changes are committed to the branch.
### Write a good commit message
The guidelines published by Chris Beams in [How to Write a Git Commit Message](https://cbea.ms/git-commit/)
help you write a good commit message:
- The commit subject and body must be separated by a blank line.
- The commit subject must start with a capital letter.
- The commit subject must not be longer than 72 characters.
- The commit subject must not end with a period.
- The commit body must not contain more than 72 characters per line.
- The commit subject or body must not contain emojis.
- Commits that change 30 or more lines across at least 3 files should
describe these changes in the commit body.
- Use the full URLs for issues, milestones, and merge requests instead of short references,
as they are displayed as plain text outside of GitLab.
- The merge request should not contain more than 10 commit messages.
- The commit subject should contain at least 3 words.
## Commit all changes
You can stage all your changes and commit them with one command:

View File

@ -15,7 +15,7 @@ title: 'Tutorial: Update Git commit messages'
Occasionally, after you've made a few commits to your branch, you realize you need
to update one or more commit messages. Perhaps you found a typo, or some automation warned you
that your commit message didn't completely align with a project's
commit message guidelines.
[commit message guidelines](../../topics/git/commit.md#write-a-good-commit-message).
Updating the message can be tricky if you don't have much practice using Git
from the command-line interface (CLI). But don't worry, even if you have only ever worked in

View File

@ -83,7 +83,7 @@ The following features are not found in standard Markdown:
- [Table of Contents](#table-of-contents)
- [Tables](#tables)
- [Task lists](#task-lists)
- [Wiki-specific Markdown](#wiki-specific-markdown)
- [Wiki-specific Markdown](project/wiki/markdown.md)
The following features are extended from standard Markdown:
@ -1446,7 +1446,7 @@ You can generate diagrams from text by using:
- [PlantUML](https://plantuml.com)
- [Kroki](https://kroki.io) to create a wide variety of diagrams.
In wikis, you can also add and edit diagrams created with the [diagrams.net editor](#diagramsnet-editor).
In wikis, you can also add and edit diagrams created with the [diagrams.net editor](project/wiki/markdown.md#diagramsnet-editor).
### Mermaid
@ -2266,149 +2266,6 @@ while the equation for the theory of relativity is E = mc<sup>2</sup>.
GitLab Flavored Markdown doesn't support the Redcarpet superscript syntax ( `x^2` ).
## Wiki-specific Markdown
The following topics show how links inside wikis behave.
When linking to wiki pages, you should use the **page slug** rather than the page name.
### Direct page link
A direct page link includes the slug for a page that points to that page,
at the base level of the wiki.
This example links to a `documentation` page at the root of your wiki:
```markdown
[Link to Documentation](documentation)
```
### Direct file link
A direct file link points to a file extension for a file, relative to the current page.
If the following example is on a page at `<your_wiki>/documentation/related`,
it links to `<your_wiki>/documentation/file.md`:
```markdown
[Link to File](file.md)
```
### Hierarchical link
A hierarchical link can be constructed relative to the current wiki page by using relative paths like `./<page>` or
`../<page>`.
If this example is on a page at `<your_wiki>/documentation/main`,
it links to `<your_wiki>/documentation/related`:
```markdown
[Link to Related Page](related)
```
If this example is on a page at `<your_wiki>/documentation/related/content`,
it links to `<your_wiki>/documentation/main`:
```markdown
[Link to Related Page](../main)
```
If this example is on a page at `<your_wiki>/documentation/main`,
it links to `<your_wiki>/documentation/related.md`:
```markdown
[Link to Related Page](related.md)
```
If this example is on a page at `<your_wiki>/documentation/related/content`,
it links to `<your_wiki>/documentation/main.md`:
```markdown
[Link to Related Page](../main.md)
```
### Root link
A root link starts with a `/` and is relative to the wiki root.
This example links to `<wiki_root>/documentation`:
```markdown
[Link to Related Page](/documentation)
```
This example links to `<wiki_root>/documentation.md`:
```markdown
[Link to Related Page](/documentation.md)
```
### diagrams.net editor
{{< history >}}
- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/322174) in GitLab 15.10.
{{< /history >}}
In wikis, you can use the [diagrams.net](https://app.diagrams.net/) editor to create diagrams. You
can also edit diagrams created with the diagrams.net editor. The diagram editor is available in both
the plain text editor and the rich text editor.
For more information, see [Diagrams.net](../administration/integration/diagrams_net.md).
#### Plain text editor
To create a diagram in the plain text editor:
1. On the wiki page you want to edit, select **Edit**.
1. In the text box, make sure you're using the plain text editor
(the button on the bottom left says **Switch to rich text editing**).
1. In the editor's toolbar, select **Insert or edit diagram** ({{< icon name="diagram" >}}).
1. Create the diagram in the [app.diagrams.net](https://app.diagrams.net/) editor.
1. Select **Save & exit**.
A Markdown image reference to the diagram is inserted in the wiki content.
To edit a diagram in the plain text editor:
1. On the wiki page you want to edit, select **Edit**.
1. In the text box, make sure you're using the plain text editor
(the button on the bottom left says **Switch to rich text editing**).
1. Position your cursor in the Markdown image reference that contains the diagram.
1. Select **Insert or edit diagram** ({{< icon name="diagram" >}}).
1. Edit the diagram in the [app.diagrams.net](https://app.diagrams.net/) editor.
1. Select **Save & exit**.
A Markdown image reference to the diagram is inserted in the wiki content,
replacing the previous diagram.
#### Rich text editor
To create a diagram in the rich text editor:
1. On the wiki page you want to edit, select **Edit**.
1. In the text box, make sure you're using the rich text editor
(the button on the bottom left says **Switch to plain text editing**).
1. In the editor's toolbar, select **More options** ({{< icon name="plus" >}}).
1. In the dropdown list, select **Create or edit diagram**.
1. Create the diagram in the [app.diagrams.net](https://app.diagrams.net/) editor.
1. Select **Save & exit**.
The diagram as visualized in the diagrams.net editor is inserted in the wiki content.
To edit a diagram in the rich text editor:
1. On the wiki page you want to edit, select **Edit**.
1. In the text box, make sure you're using the rich text editor
(the button on the bottom left says **Switch to plain text editing**).
1. Select the diagram that you want to edit.
1. In the floating toolbar, select **Edit diagram** ({{< icon name="diagram" >}}).
1. Edit the diagram in the [app.diagrams.net](https://app.diagrams.net/) editor.
1. Select **Save & exit**.
The selected diagram is replaced with an updated version.
## References
- The [GitLab Flavored Markdown development guidelines](../development/gitlab_flavored_markdown/_index.md) is a developer-facing document that describes in detail the various goals, tools, implementations, and terms related to the GLFM specification.

View File

@ -60,6 +60,20 @@ string `Changelog: feature` to your commit message, like this:
Changelog: feature
```
If your merge request has multiple commits, add the `Changelog` entry to the first commit.
This ensures the correct entry is generated when you squash commits.
The `Changelog` trailer accepts these values:
- `added`: New feature
- `fixed`: Bug fix
- `changed`: Feature change
- `deprecated`: New deprecation
- `removed`: Feature removal
- `security`: Security fix
- `performance`: Performance improvement
- `other`: Other
## Create a changelog
Changelogs are generated from the command line, using either the API or the

View File

@ -26,7 +26,7 @@ Each wiki is a separate Git repository.
You can create and edit wiki pages through the GitLab web interface or
[locally using Git](#create-or-edit-wiki-pages-locally).
Wiki pages written in Markdown support all [Markdown features](../../markdown.md) and provide
[wiki-specific behavior](../../markdown.md#wiki-specific-markdown) for links.
[wiki-specific behavior](markdown.md) for links.
Wiki pages display a [sidebar](#sidebar), which you can customize.

View File

@ -0,0 +1,154 @@
---
stage: Plan
group: Knowledge
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://handbook.gitlab.com/handbook/product/ux/technical-writing/#assignments
title: Wiki-specific Markdown
---
{{< details >}}
- Tier: Free, Premium, Ultimate
- Offering: GitLab.com, GitLab Self-Managed, GitLab Dedicated
{{< /details >}}
The following topics show how links inside wikis behave.
When linking to wiki pages, you should use the **page slug** rather than the page name.
## Direct page link
A direct page link includes the slug for a page that points to that page,
at the base level of the wiki.
This example links to a `documentation` page at the root of your wiki:
```markdown
[Link to Documentation](documentation)
```
## Direct file link
A direct file link points to a file extension for a file, relative to the current page.
If the following example is on a page at `<your_wiki>/documentation/related`,
it links to `<your_wiki>/documentation/file.md`:
```markdown
[Link to File](file.md)
```
## Hierarchical link
A hierarchical link can be constructed relative to the current wiki page by using relative paths like `./<page>` or
`../<page>`.
If this example is on a page at `<your_wiki>/documentation/main`,
it links to `<your_wiki>/documentation/related`:
```markdown
[Link to Related Page](related)
```
If this example is on a page at `<your_wiki>/documentation/related/content`,
it links to `<your_wiki>/documentation/main`:
```markdown
[Link to Related Page](../main)
```
If this example is on a page at `<your_wiki>/documentation/main`,
it links to `<your_wiki>/documentation/related.md`:
```markdown
[Link to Related Page](related.md)
```
If this example is on a page at `<your_wiki>/documentation/related/content`,
it links to `<your_wiki>/documentation/main.md`:
```markdown
[Link to Related Page](../main.md)
```
## Root link
A root link starts with a `/` and is relative to the wiki root.
This example links to `<wiki_root>/documentation`:
```markdown
[Link to Related Page](/documentation)
```
This example links to `<wiki_root>/documentation.md`:
```markdown
[Link to Related Page](/documentation.md)
```
## diagrams.net editor
{{< history >}}
- [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/322174) in GitLab 15.10.
{{< /history >}}
In wikis, you can use the [diagrams.net](https://app.diagrams.net/) editor to create diagrams. You
can also edit diagrams created with the diagrams.net editor. The diagram editor is available in both
the plain text editor and the rich text editor.
For more information, see [Diagrams.net](../../../administration/integration/diagrams_net.md).
### Plain text editor
To create a diagram in the plain text editor:
1. On the wiki page you want to edit, select **Edit**.
1. In the text box, make sure you're using the plain text editor
(the button on the bottom left says **Switch to rich text editing**).
1. In the editor's toolbar, select **Insert or edit diagram** ({{< icon name="diagram" >}}).
1. Create the diagram in the [app.diagrams.net](https://app.diagrams.net/) editor.
1. Select **Save & exit**.
A Markdown image reference to the diagram is inserted in the wiki content.
To edit a diagram in the plain text editor:
1. On the wiki page you want to edit, select **Edit**.
1. In the text box, make sure you're using the plain text editor
(the button on the bottom left says **Switch to rich text editing**).
1. Position your cursor in the Markdown image reference that contains the diagram.
1. Select **Insert or edit diagram** ({{< icon name="diagram" >}}).
1. Edit the diagram in the [app.diagrams.net](https://app.diagrams.net/) editor.
1. Select **Save & exit**.
A Markdown image reference to the diagram is inserted in the wiki content,
replacing the previous diagram.
### Rich text editor
To create a diagram in the rich text editor:
1. On the wiki page you want to edit, select **Edit**.
1. In the text box, make sure you're using the rich text editor
(the button on the bottom left says **Switch to plain text editing**).
1. In the editor's toolbar, select **More options** ({{< icon name="plus" >}}).
1. In the dropdown list, select **Create or edit diagram**.
1. Create the diagram in the [app.diagrams.net](https://app.diagrams.net/) editor.
1. Select **Save & exit**.
The diagram as visualized in the diagrams.net editor is inserted in the wiki content.
To edit a diagram in the rich text editor:
1. On the wiki page you want to edit, select **Edit**.
1. In the text box, make sure you're using the rich text editor
(the button on the bottom left says **Switch to plain text editing**).
1. Select the diagram that you want to edit.
1. In the floating toolbar, select **Edit diagram** ({{< icon name="diagram" >}}).
1. Edit the diagram in the [app.diagrams.net](https://app.diagrams.net/) editor.
1. Select **Save & exit**.
The selected diagram is replaced with an updated version.

View File

@ -15,8 +15,8 @@ module Gitlab
@ctx = ctx
@errors = []
if objects.count <= 1 # rubocop:disable Style/IfUnlessModifier
@errors.push('invalid interpolation access pattern')
if objects.count <= 1
@errors.push('invalid pattern used for interpolation. valid pattern is $[[ inputs.input ]]')
end
if access.bytesize > MAX_ACCESS_BYTESIZE # rubocop:disable Style/IfUnlessModifier
@ -48,7 +48,7 @@ module Gitlab
@value ||= objects.inject(@ctx) do |memo, value|
key = value.to_sym
break @errors.push("unknown interpolation key: `#{key}`") unless memo.key?(key)
break @errors.push("unknown input name provided: `#{key}`") unless memo.key?(key)
memo.fetch(key)
end

View File

@ -5855,6 +5855,12 @@ msgstr ""
msgid "AiPowered|Tanuki AI icon"
msgstr ""
msgid "AiPowered|This settings applies Namespace/Instance-wide, Subgroup and project access controls are coming soon.%{br}By turning this on, you accept the %{linkStart}GitLab AI functionality terms%{linkEnd}."
msgstr ""
msgid "AiPowered|Turn on IDE features"
msgstr ""
msgid "AiPowered|Turn on experiment and beta GitLab Duo features"
msgstr ""
@ -22993,9 +22999,6 @@ msgstr ""
msgid "DuoCodeReview|GitLab Duo Code Review"
msgstr ""
msgid "DuoCodeReview|Hey :wave: I'm reviewing your merge request now. I will let you know when I'm finished."
msgstr ""
msgid "DuoCodeReview|I encountered some problems while responding to your query. Please try again later."
msgstr ""
@ -23008,6 +23011,9 @@ msgstr ""
msgid "DuoCodeReview|I've received your Duo Code Review request, and will review your code shortly."
msgstr ""
msgid "DuoCodeReview|is reviewing your merge request and will let you know when it's finished"
msgstr ""
msgid "DuoEnterpriseDiscover|AI Impact Dashboard measures the ROI of AI"
msgstr ""
@ -28232,6 +28238,9 @@ msgstr ""
msgid "GithubIntegration|This requires mirroring your GitHub repository to this project. %{docs_link}"
msgstr ""
msgid "Gitlab Duo Core"
msgstr ""
msgid "Gitpod"
msgstr ""
@ -30162,7 +30171,10 @@ msgstr ""
msgid "GroupsNew|e.g. h8d3f016698e…"
msgstr ""
msgid "GroupsTree|Are you sure you want to leave the \"%{fullName}\" group?"
msgid "GroupsTree|An error occurred while leaving the group. Please refresh the page to try again."
msgstr ""
msgid "GroupsTree|Are you sure you want to leave \"%{fullName}\"?"
msgstr ""
msgid "GroupsTree|Delete"
@ -30183,6 +30195,18 @@ msgstr ""
msgid "GroupsTree|Options"
msgstr ""
msgid "GroupsTree|When you leave this group:"
msgstr ""
msgid "GroupsTree|You lose access to all projects within this group"
msgstr ""
msgid "GroupsTree|You need an invitation to rejoin"
msgstr ""
msgid "GroupsTree|Your assigned issues and merge requests remain, but you cannot view or modify them"
msgstr ""
msgid "Groups|An error occurred updating this group. Please try again."
msgstr ""
@ -35626,6 +35650,9 @@ msgstr ""
msgid "Leave project"
msgstr ""
msgid "Left the '%{group_name}' group successfully."
msgstr ""
msgid "Legacy Prometheus integrations cannot currently be removed"
msgstr ""

View File

@ -68,7 +68,7 @@
"@gitlab/ui": "113.2.1",
"@gitlab/vue-router-vue3": "npm:vue-router@4.5.1",
"@gitlab/vuex-vue3": "npm:vuex@4.1.0",
"@gitlab/web-ide": "^0.0.1-dev-20250414030534",
"@gitlab/web-ide": "^0.0.1-dev-20250430143302",
"@gleam-lang/highlight.js-gleam": "^1.5.0",
"@mattiasbuelens/web-streams-adapter": "^0.1.0",
"@rails/actioncable": "7.1.501",

View File

@ -45,8 +45,8 @@ prometheus:
tracer:
jaeger:
enabled: false
# https://gitlab.com/gitlab-org/gitlab/-/issues/471172
# https://gitlab.com/gitlab-org/quality/engineering-productivity/master-broken-incidents/-/issues/12503#note_2479345136
gitlab_http_router:
enabled: true
enabled: false
mise:
enabled: true

View File

@ -8,6 +8,7 @@ module RuboCop
# See https://gitlab.com/gitlab-org/gitlab/-/issues/536393
class RandomCronSchedule < RuboCop::Cop::Base
MSG = "Avoid randomized cron expressions. This can lead to missed executions. " \
"Use Gitlab::Scheduling::ScheduleWithinWorker if you want to add random jitter. " \
"See https://gitlab.com/gitlab-org/gitlab/-/issues/536393"
RESTRICT_ON_SEND = %i[rand].freeze

View File

@ -19,6 +19,52 @@ RSpec.describe Projects::RunnersController, feature_category: :fleet_visibility
sign_in(user)
end
describe '#show' do
context 'when user is maintainer' do
before_all do
project.add_maintainer(user)
end
it 'renders show with 200 status code' do
get :show, params: params
expect(response).to have_gitlab_http_status(:ok)
end
context 'with an instance runner' do
let(:runner) { create(:ci_runner, :instance) }
it 'renders show with 200 status code' do
get :show, params: params
expect(response).to have_gitlab_http_status(:ok)
end
end
end
context 'when user is not maintainer' do
before_all do
project.add_developer(user)
end
it 'renders a 404' do
get :show, params: params
expect(response).to have_gitlab_http_status(:not_found)
end
context 'with an instance runner' do
let(:runner) { create(:ci_runner, :instance) }
it 'renders a 404' do
get :show, params: params
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
describe '#new' do
let(:params) do
{

View File

@ -12,11 +12,13 @@ import {
getGroupMembers,
createGroup,
getSharedGroups,
deleteGroupMember,
} from '~/api/groups_api';
const mockApiVersion = 'v4';
const mockUrlRoot = '/gitlab';
const mockGroupId = '99';
const mockUserId = '47';
describe('GroupsApi', () => {
let mock;
@ -115,6 +117,22 @@ describe('GroupsApi', () => {
});
});
describe('deleteGroupMember', () => {
beforeEach(() => {
jest.spyOn(axios, 'delete');
});
it('deletes to the correct URL', () => {
const expectedUrl = `${mockUrlRoot}/api/${mockApiVersion}/groups/${mockGroupId}/members/${mockUserId}`;
mock.onDelete(expectedUrl).replyOnce(HTTP_STATUS_OK);
return deleteGroupMember(mockGroupId, mockUserId).then(() => {
expect(axios.delete).toHaveBeenCalledWith(expectedUrl);
});
});
});
describe('createGroup', () => {
it('posts to the correct URL and returns the data', async () => {
const body = { name: 'Foo bar', path: 'foo-bar' };

View File

@ -278,6 +278,7 @@ describe('AdminRunnersApp', () => {
expect(runnerActions.props()).toEqual({
runner,
editUrl: runner.editAdminUrl,
size: 'medium',
});
});

View File

@ -15,7 +15,7 @@ describe('RunnerActionsCell', () => {
const findRunnerPauseBtn = () => wrapper.findComponent(RunnerPauseButton);
const findDeleteBtn = () => wrapper.findComponent(RunnerDeleteButton);
const createComponent = ({ runner = {}, ...props } = {}) => {
const createComponent = ({ runner = {}, ...props } = {}, options) => {
wrapper = shallowMountExtended(RunnerActionsCell, {
propsData: {
editUrl: mockRunner.editAdminUrl,
@ -28,6 +28,7 @@ describe('RunnerActionsCell', () => {
},
...props,
},
...options,
});
};
@ -35,9 +36,18 @@ describe('RunnerActionsCell', () => {
it('Displays the runner edit link with the correct href', () => {
createComponent();
expect(findEditBtn().props('size')).toBe('medium');
expect(findEditBtn().attributes('href')).toBe(mockRunner.editAdminUrl);
});
it('Displays button with size prop', () => {
createComponent({
size: 'small',
});
expect(findEditBtn().props('size')).toBe('small');
});
it('Does not render the runner edit link when user cannot update', () => {
createComponent({
runner: {
@ -64,9 +74,18 @@ describe('RunnerActionsCell', () => {
it('Renders a compact pause button', () => {
createComponent();
expect(findRunnerPauseBtn().props('size')).toBe('medium');
expect(findRunnerPauseBtn().props('compact')).toBe(true);
});
it('Displays button with size prop', () => {
createComponent({
size: 'small',
});
expect(findRunnerPauseBtn().props('size')).toBe('small');
});
it('Does not render the runner pause button when user cannot update', () => {
createComponent({
runner: {
@ -85,9 +104,18 @@ describe('RunnerActionsCell', () => {
it('Renders a compact delete button', () => {
createComponent();
expect(findDeleteBtn().props('size')).toBe('medium');
expect(findDeleteBtn().props('compact')).toBe(true);
});
it('Displays button with size prop', () => {
createComponent({
size: 'small',
});
expect(findDeleteBtn().props('size')).toBe('small');
});
it('Passes runner data to delete button', () => {
createComponent({
runner: mockRunner,
@ -131,4 +159,19 @@ describe('RunnerActionsCell', () => {
expect(findDeleteBtn().exists()).toBe(false);
});
});
describe('Slot', () => {
it('shows additional content', () => {
createComponent(
{},
{
slots: {
default: '<div>actions</div>',
},
},
);
expect(wrapper.text()).toContain('actions');
});
});
});

View File

@ -75,8 +75,9 @@ describe('RunnerDeleteButton', () => {
});
it('Passes other attributes to the button', () => {
createComponent({ props: { category: 'secondary' } });
createComponent({ props: { category: 'secondary', size: 'small' } });
expect(findBtn().props('size')).toBe('small');
expect(findBtn().props('category')).toBe('secondary');
});

View File

@ -39,6 +39,13 @@ describe('RunnerEditButton', () => {
expect(findButton().attributes('href')).toBe('/edit');
});
it('Passes other attributes to the button', () => {
createComponent({ props: { size: 'small' } });
expect(findButton().props('size')).toBe('small');
expect(findButton().props('icon')).toBe('pencil');
});
describe('When no href is provided', () => {
beforeEach(() => {
createComponent({ props: { href: null } });

View File

@ -119,6 +119,29 @@ describe('RunnerList', () => {
expect(findCell({ fieldKey: 'actions' }).exists()).toBe(true);
});
describe('fixed table appearance', () => {
it('is enabled by default', () => {
createComponent({
stubs: {
GlTableLite: stubComponent(GlTableLite),
},
});
expect(findTable().attributes('fixed')).toBe('true');
});
it('can be disabled', () => {
createComponent({
props: { fixed: true },
stubs: {
GlTableLite: stubComponent(GlTableLite),
},
});
expect(findTable().attributes('fixed')).toBe('true');
});
});
describe('When the list is checkable', () => {
beforeEach(() => {
createComponent(

View File

@ -67,6 +67,7 @@ describe('RunnerPauseButton', () => {
});
it(`Displays a ${expectedIcon} button`, () => {
expect(findBtn().props('size')).toBe('medium');
expect(findBtn().props('loading')).toBe(false);
expect(findBtn().props('icon')).toBe(expectedIcon);
});
@ -123,6 +124,16 @@ describe('RunnerPauseButton', () => {
);
});
it('Displays button with size prop', () => {
createComponent({
props: {
size: 'small',
},
});
expect(findBtn().props('size')).toBe('small');
});
it('Shows loading state', () => {
createComponent({ loading: true });

View File

@ -38,6 +38,7 @@ RSpec.describe 'Organizations (GraphQL fixtures)', feature_category: :cell do
let_it_be(:organization) { organizations.first }
let_it_be(:groups) { create_list(:group, 3, organization: organization) }
let_it_be(:group) { groups.first }
let_it_be(:group_owner) { create(:group_member, :owner, group: group, user: create(:user)) }
let_it_be(:projects) do
groups.map do |group|
create(:project, :public, namespace: group, organization: organization)

View File

@ -308,12 +308,12 @@ describe('AppComponent', () => {
it('updates props which show modal confirmation dialog', () => {
const group = { ...mockParentGroupItem };
expect(vm.groupLeaveConfirmationMessage).toBe('');
expect(vm.groupLeaveConfirmationTitle).toBe('');
vm.showLeaveGroupModal(group, mockParentGroupItem);
expect(vm.isModalVisible).toBe(true);
expect(vm.groupLeaveConfirmationMessage).toBe(
`Are you sure you want to leave the "${group.fullName}" group?`,
expect(vm.groupLeaveConfirmationTitle).toBe(
`Are you sure you want to leave "${group.fullName}"?`,
);
});
});
@ -526,8 +526,14 @@ describe('AppComponent', () => {
const findGlModal = wrapper.findComponent(GlModal);
expect(findGlModal.exists()).toBe(true);
expect(findGlModal.attributes('title')).toBe('Are you sure?');
expect(findGlModal.attributes('title')).toBe('');
expect(findGlModal.props('actionPrimary').text).toBe('Leave group');
expect(findGlModal.text()).toContain('When you leave this group:');
expect(findGlModal.text()).toContain('You lose access to all projects within this group');
expect(findGlModal.text()).toContain(
'Your assigned issues and merge requests remain, but you cannot view or modify them',
);
expect(findGlModal.text()).toContain('You need an invitation to rejoin');
});
});
});

View File

@ -73,7 +73,11 @@ describe('your work groups resolver', () => {
visibility: 'public',
createdAt: mockGroup.created_at,
updatedAt: mockGroup.updated_at,
userPermissions: { removeGroup: true, viewEditPage: true },
userPermissions: {
canLeave: false,
removeGroup: true,
viewEditPage: true,
},
maxAccessLevel: { integerValue: 50 },
children: [
{

View File

@ -3,7 +3,11 @@ import organizationProjectsGraphQlResponse from 'test_fixtures/graphql/organizat
import { formatGroups, formatProjects, timestampType } from '~/organizations/shared/utils';
import { formatGraphQLProjects } from '~/vue_shared/components/projects_list/formatter';
import { SORT_CREATED_AT, SORT_UPDATED_AT, SORT_NAME } from '~/organizations/shared/constants';
import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
import {
ACTION_EDIT,
ACTION_DELETE,
ACTION_LEAVE,
} from '~/vue_shared/components/list_actions/constants';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import {
TIMESTAMP_TYPE_CREATED_AT,
@ -29,7 +33,7 @@ const {
} = organizationProjectsGraphQlResponse;
describe('formatGroups', () => {
it('correctly formats the groups with edit and delete permissions', () => {
it('correctly formats the groups with edit, delete, and leave permissions', () => {
const [firstMockGroup] = organizationGroups;
const formattedGroups = formatGroups(organizationGroups);
const [firstFormattedGroup] = formattedGroups;
@ -43,7 +47,7 @@ describe('formatGroups', () => {
accessLevel: {
integerValue: 50,
},
availableActions: [ACTION_EDIT, ACTION_DELETE],
availableActions: [ACTION_EDIT, ACTION_LEAVE, ACTION_DELETE],
children: [],
childrenLoading: false,
hasChildren: false,
@ -51,7 +55,7 @@ describe('formatGroups', () => {
expect(formattedGroups.length).toBe(organizationGroups.length);
});
it('correctly formats the groups without edit or delete permissions', () => {
it('correctly formats the groups without edit, delete, and leave permissions', () => {
const nonDeletableGroup = organizationGroups[1];
const formattedGroups = formatGroups(organizationGroups);
const nonDeletableFormattedGroup = formattedGroups[1];

View File

@ -107,6 +107,7 @@ describe('OpenMrBadge', () => {
expect(badge.exists()).toBe(true);
expect(badge.props('variant')).toBe('success');
expect(badge.props('icon')).toBe('merge-request');
expect(badge.props('tag')).toBe('a');
expect(badge.attributes('title')).toBe(
'Open merge requests created in the past 30 days that target this branch and modify this file.',
);

View File

@ -19,6 +19,7 @@ import {
TODO_ACTION_TYPE_SSH_KEY_EXPIRING_SOON,
TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED,
TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED,
TODO_ACTION_TYPE_DUO_CORE_ACCESS_GRANTED,
} from '~/todos/constants';
import { SAML_HIDDEN_TODO } from '../mock_data';
@ -81,6 +82,7 @@ describe('TodoItemBody', () => {
${TODO_ACTION_TYPE_SSH_KEY_EXPIRING_SOON} | ${'Your SSH key is expiring soon.'} | ${false}
${TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED} | ${'You now have access to AI-powered features. Learn how to set up Code Suggestions and Chat in your IDE.'} | ${false}
${TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED} | ${'You now have access to AI-powered features. Learn how to set up Code Suggestions and Chat in your IDE.'} | ${false}
${TODO_ACTION_TYPE_DUO_CORE_ACCESS_GRANTED} | ${'You now have access to AI-powered features. Learn how to set up Code Suggestions and Chat in your IDE.'} | ${false}
`('renders "$text" for the "$actionName" action', ({ actionName, text, showsAuthor }) => {
createComponent({ action: actionName, memberAccessType: 'group' });
expect(wrapper.text()).toContain(text);
@ -105,6 +107,7 @@ describe('TodoItemBody', () => {
it.each([
TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED,
TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED,
TODO_ACTION_TYPE_DUO_CORE_ACCESS_GRANTED,
])('when todo action is `%s`, avatar is not shown', (action) => {
createComponent({ action });
expect(wrapper.findComponent(GlAvatarLink).exists()).toBe(false);

View File

@ -12,6 +12,7 @@ import {
TODO_TARGET_TYPE_SSH_KEY,
TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED,
TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED,
TODO_ACTION_TYPE_DUO_CORE_ACCESS_GRANTED,
} from '~/todos/constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import { DESIGN_TODO, MR_BUILD_FAILED_TODO } from '../mock_data';
@ -98,6 +99,7 @@ describe('TodoItemTitle', () => {
action | icon | showsIcon
${TODO_ACTION_TYPE_DUO_PRO_ACCESS_GRANTED} | ${'book'} | ${true}
${TODO_ACTION_TYPE_DUO_ENTERPRISE_ACCESS_GRANTED} | ${'book'} | ${true}
${TODO_ACTION_TYPE_DUO_CORE_ACCESS_GRANTED} | ${'book'} | ${true}
`('renders "$icon" for the "$action" action', ({ action, icon, showsIcon }) => {
createComponent({ ...mockToDo, action });

View File

@ -13,9 +13,12 @@ describe('TokenAccessApp component', () => {
const findTokenPermissions = () => wrapper.findComponent(TokenPermissions);
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
const createComponent = ({ allowPushRepositoryForJobToken = true } = {}) => {
const createComponent = ({
allowPushRepositoryForJobToken = true,
removeLimitCiJobTokenScope = true,
} = {}) => {
wrapper = shallowMount(TokenAccessApp, {
provide: { glFeatures: { allowPushRepositoryForJobToken } },
provide: { glFeatures: { allowPushRepositoryForJobToken, removeLimitCiJobTokenScope } },
});
};
@ -38,8 +41,8 @@ describe('TokenAccessApp component', () => {
expect(findIntersectionObserver().exists()).toBe(true);
});
it('renders/does not render the outbound token access component', () => {
expect(findOutboundTokenAccess().exists()).toBe(expected);
it('does not render the outbound token access component', () => {
expect(findOutboundTokenAccess().exists()).toBe(false);
});
it('renders/does not render the inbound token access component', () => {
@ -61,4 +64,15 @@ describe('TokenAccessApp component', () => {
expect(findTokenPermissions().exists()).toBe(false);
});
});
describe('when removeLimitCiJobTokenScope feature flag is disabled', () => {
beforeEach(() => {
createComponent({ removeLimitCiJobTokenScope: false });
findIntersectionObserver().vm.$emit('update', { isIntersecting: true });
});
it('renders the outbound token access component', () => {
expect(findOutboundTokenAccess().exists()).toBe(true);
});
});
});

View File

@ -410,6 +410,14 @@ describe('useAccessTokens store', () => {
}),
);
});
it('hides the new token component', async () => {
store.token = 'new token';
mockAxios.onDelete().replyOnce(HTTP_STATUS_NO_CONTENT);
await store.revokeToken(1);
expect(store.token).toBeNull();
});
});
describe('rotateToken', () => {

View File

@ -0,0 +1,119 @@
import { GlModal } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import GroupListItemLeaveModal from '~/vue_shared/components/groups_list/group_list_item_leave_modal.vue';
import { groups } from 'jest/vue_shared/components/groups_list/mock_data';
import waitForPromises from 'helpers/wait_for_promises';
import { renderLeaveSuccessToast } from '~/vue_shared/components/groups_list/utils';
import { createAlert } from '~/alert';
import { deleteGroupMember } from '~/api/groups_api';
jest.mock('~/vue_shared/components/groups_list/utils', () => ({
...jest.requireActual('~/vue_shared/components/groups_list/utils'),
renderLeaveSuccessToast: jest.fn(),
}));
jest.mock('~/alert');
jest.mock('~/api/groups_api');
describe('GroupListItemLeaveModal', () => {
let wrapper;
const userId = 1;
const [group] = groups;
const defaultProps = {
modalId: '123',
group,
};
const createComponent = ({ props = {} } = {}) => {
window.gon.current_user_id = userId;
wrapper = shallowMountExtended(GroupListItemLeaveModal, {
propsData: { ...defaultProps, ...props },
});
};
const findGlModal = () => wrapper.findComponent(GlModal);
const firePrimaryEvent = () => findGlModal().vm.$emit('primary', { preventDefault: jest.fn() });
beforeEach(createComponent);
it('renders GlModal with correct props', () => {
expect(findGlModal().props()).toMatchObject({
visible: false,
modalId: defaultProps.modalId,
title: `Are you sure you want to leave "${group.fullName}"?`,
actionPrimary: {
text: 'Leave group',
attributes: {
variant: 'danger',
},
},
actionCancel: {
text: 'Cancel',
},
});
});
it('renders body', () => {
expect(findGlModal().text()).toContain('When you leave this group:');
expect(findGlModal().text()).toContain('You lose access to all projects within this group');
expect(findGlModal().text()).toContain(
'Your assigned issues and merge requests remain, but you cannot view or modify them',
);
expect(findGlModal().text()).toContain('You need an invitation to rejoin');
});
describe('when leave is confirmed', () => {
describe('when API call is successful', () => {
it('calls deleteGroupMember, properly sets loading state, and emits confirm event', async () => {
deleteGroupMember.mockResolvedValueOnce();
await firePrimaryEvent();
expect(deleteGroupMember).toHaveBeenCalledWith(group.id, userId);
expect(findGlModal().props('actionPrimary').attributes.loading).toEqual(true);
await waitForPromises();
expect(findGlModal().props('actionPrimary').attributes.loading).toEqual(false);
expect(wrapper.emitted('success')).toEqual([[]]);
expect(renderLeaveSuccessToast).toHaveBeenCalledWith(group);
expect(createAlert).not.toHaveBeenCalled();
});
});
describe('when API call is not successful', () => {
const error = new Error();
it('calls deleteGroupMember, properly sets loading state, and shows error alert', async () => {
deleteGroupMember.mockRejectedValue(error);
await firePrimaryEvent();
expect(deleteGroupMember).toHaveBeenCalledWith(group.id, userId);
expect(findGlModal().props('actionPrimary').attributes.loading).toEqual(true);
await waitForPromises();
expect(findGlModal().props('actionPrimary').attributes.loading).toEqual(false);
expect(wrapper.emitted('success')).toBeUndefined();
expect(renderLeaveSuccessToast).not.toHaveBeenCalled();
expect(createAlert).toHaveBeenCalledWith({
message:
'An error occurred while leaving the group. Please refresh the page to try again.',
error,
captureError: true,
});
});
});
});
describe('when change is fired', () => {
beforeEach(() => {
findGlModal().vm.$emit('change', false);
});
it('emits change event', () => {
expect(wrapper.emitted('change')).toMatchObject([[]]);
});
});
});

View File

@ -9,6 +9,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import GroupListItemDeleteModal from '~/vue_shared/components/groups_list/group_list_item_delete_modal.vue';
import GroupListItemInactiveBadge from '~/vue_shared/components/groups_list/group_list_item_inactive_badge.vue';
import GroupListItemPreventDeleteModal from '~/vue_shared/components/groups_list/group_list_item_prevent_delete_modal.vue';
import GroupListItemLeaveModal from '~/vue_shared/components/groups_list/group_list_item_leave_modal.vue';
import {
VISIBILITY_TYPE_ICON,
VISIBILITY_LEVEL_INTERNAL_STRING,
@ -17,7 +18,11 @@ import {
import { ACCESS_LEVEL_LABELS, ACCESS_LEVEL_NO_ACCESS_INTEGER } from '~/access_level/constants';
import ListActions from '~/vue_shared/components/list_actions/list_actions.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
import {
ACTION_EDIT,
ACTION_DELETE,
ACTION_LEAVE,
} from '~/vue_shared/components/list_actions/constants';
import {
TIMESTAMP_TYPE_CREATED_AT,
TIMESTAMP_TYPE_UPDATED_AT,
@ -62,14 +67,18 @@ describe('GroupsListItem', () => {
const findGroupDescription = () => wrapper.findByTestId('description');
const findVisibilityIcon = () => findAvatarLabeled().findComponent(GlIcon);
const findListActions = () => wrapper.findComponent(ListActions);
const findConfirmationModal = () => wrapper.findComponent(GroupListItemDeleteModal);
const findDeleteConfirmationModal = () => wrapper.findComponent(GroupListItemDeleteModal);
const findPreventDeleteModal = () => wrapper.findComponent(GroupListItemPreventDeleteModal);
const findLeaveModal = () => wrapper.findComponent(GroupListItemLeaveModal);
const findAccessLevelBadge = () => wrapper.findByTestId('user-access-role');
const findTimeAgoTooltip = () => wrapper.findComponent(TimeAgoTooltip);
const fireLeaveAction = () => findListActions().props('actions')[ACTION_LEAVE].action();
const fireDeleteAction = () => findListActions().props('actions')[ACTION_DELETE].action();
const findInactiveBadge = () => wrapper.findComponent(GroupListItemInactiveBadge);
const deleteModalFireConfirmEvent = async () => {
findConfirmationModal().vm.$emit('confirm', {
findDeleteConfirmationModal().vm.$emit('confirm', {
preventDefault: jest.fn(),
});
await nextTick();
@ -278,8 +287,47 @@ describe('GroupsListItem', () => {
[ACTION_DELETE]: {
action: expect.any(Function),
},
[ACTION_LEAVE]: {
action: expect.any(Function),
},
},
availableActions: [ACTION_EDIT, ACTION_DELETE],
availableActions: [ACTION_EDIT, ACTION_DELETE, ACTION_LEAVE],
});
});
describe('when group has leave action', () => {
beforeEach(createComponent);
it('renders hidden leave modal', () => {
expect(findLeaveModal().props('visible')).toBe(false);
});
describe('when leave action is fired', () => {
beforeEach(async () => {
await fireLeaveAction();
});
it('shows leave modal', () => {
expect(findLeaveModal().props('visible')).toBe(true);
});
describe('when leave modal emits visibility change', () => {
it("updates the modal's visibility prop", async () => {
findLeaveModal().vm.$emit('change', false);
await nextTick();
expect(findLeaveModal().props('visible')).toBe(false);
});
});
describe('when leave modal emits success event', () => {
it('emits refetch event', () => {
findLeaveModal().vm.$emit('success');
expect(wrapper.emitted('refetch')).toEqual([[]]);
});
});
});
});
@ -327,7 +375,7 @@ describe('GroupsListItem', () => {
});
it('displays confirmation modal with correct props', () => {
expect(findConfirmationModal().props()).toMatchObject({
expect(findDeleteConfirmationModal().props()).toMatchObject({
visible: true,
phrase: groupWithDeleteAction.fullName,
confirmLoading: false,
@ -340,12 +388,12 @@ describe('GroupsListItem', () => {
axiosMock.onDelete(`/${groupWithDeleteAction.fullPath}`).reply(200);
await deleteModalFireConfirmEvent();
expect(findConfirmationModal().props('confirmLoading')).toBe(true);
expect(findDeleteConfirmationModal().props('confirmLoading')).toBe(true);
await waitForPromises();
expect(axiosMock.history.delete[0].params).toEqual(MOCK_DELETE_PARAMS);
expect(findConfirmationModal().props('confirmLoading')).toBe(false);
expect(findDeleteConfirmationModal().props('confirmLoading')).toBe(false);
expect(wrapper.emitted('refetch')).toEqual([[]]);
expect(renderDeleteSuccessToast).toHaveBeenCalledWith(groupWithDeleteAction);
expect(createAlert).not.toHaveBeenCalled();
@ -357,12 +405,12 @@ describe('GroupsListItem', () => {
axiosMock.onDelete(`/${groupWithDeleteAction.fullPath}`).networkError();
await deleteModalFireConfirmEvent();
expect(findConfirmationModal().props('confirmLoading')).toBe(true);
expect(findDeleteConfirmationModal().props('confirmLoading')).toBe(true);
await waitForPromises();
expect(axiosMock.history.delete[0].params).toEqual(MOCK_DELETE_PARAMS);
expect(findConfirmationModal().props('confirmLoading')).toBe(false);
expect(findDeleteConfirmationModal().props('confirmLoading')).toBe(false);
expect(wrapper.emitted('refetch')).toBeUndefined();
expect(createAlert).toHaveBeenCalledWith({
message:
@ -377,11 +425,11 @@ describe('GroupsListItem', () => {
describe('when change is fired', () => {
beforeEach(() => {
findConfirmationModal().vm.$emit('change', false);
findDeleteConfirmationModal().vm.$emit('change', false);
});
it('updates visibility prop', () => {
expect(findConfirmationModal().props('visible')).toBe(false);
expect(findDeleteConfirmationModal().props('visible')).toBe(false);
});
});
});
@ -405,7 +453,7 @@ describe('GroupsListItem', () => {
});
it('does not display confirmation modal', () => {
expect(findConfirmationModal().exists()).toBe(false);
expect(findDeleteConfirmationModal().exists()).toBe(false);
});
});

View File

@ -1,4 +1,8 @@
import { ACTION_EDIT, ACTION_DELETE } from '~/vue_shared/components/list_actions/constants';
import {
ACTION_EDIT,
ACTION_DELETE,
ACTION_LEAVE,
} from '~/vue_shared/components/list_actions/constants';
export const groups = [
{
@ -19,7 +23,7 @@ export const groups = [
integerValue: 10,
},
editPath: 'http://127.0.0.1:3000/groups/gitlab-org/-/edit',
availableActions: [ACTION_EDIT, ACTION_DELETE],
availableActions: [ACTION_EDIT, ACTION_DELETE, ACTION_LEAVE],
createdAt: '2023-09-19T14:42:38Z',
updatedAt: '2024-04-24T03:47:38Z',
lastActivityAt: '2024-05-24T03:47:38Z',
@ -44,7 +48,7 @@ export const groups = [
integerValue: 20,
},
editPath: 'http://127.0.0.1:3000/groups/gitlab-org/test-subgroup/-/edit',
availableActions: [ACTION_EDIT, ACTION_DELETE],
availableActions: [ACTION_EDIT, ACTION_DELETE, ACTION_LEAVE],
createdAt: '2023-09-19T14:42:38Z',
updatedAt: '2024-04-24T03:47:38Z',
lastActivityAt: '2024-05-24T03:47:38Z',

View File

@ -1,27 +1,33 @@
import { deleteParams, renderDeleteSuccessToast } from '~/vue_shared/components/groups_list/utils';
import {
deleteParams,
renderDeleteSuccessToast,
renderLeaveSuccessToast,
} from '~/vue_shared/components/groups_list/utils';
import toast from '~/vue_shared/plugins/global_toast';
jest.mock('~/vue_shared/plugins/global_toast');
const MOCK_GROUP = {
fullName: 'Group',
fullPath: 'path/to/group',
};
const MOCK_GROUP_NO_DELAY_DELETION = {
fullName: 'No Delay Group',
fullPath: 'path/to/group/1',
...MOCK_GROUP,
isAdjournedDeletionEnabled: false,
markedForDeletionOn: null,
permanentDeletionDate: null,
};
const MOCK_GROUP_WITH_DELAY_DELETION = {
fullName: 'With Delay Group',
fullPath: 'path/to/group/2',
...MOCK_GROUP,
isAdjournedDeletionEnabled: true,
markedForDeletionOn: null,
permanentDeletionDate: '2024-03-31',
};
const MOCK_GROUP_PENDING_DELETION = {
fullName: 'Pending Deletion Group',
fullPath: 'path/to/group/3',
...MOCK_GROUP,
isAdjournedDeletionEnabled: true,
markedForDeletionOn: '2024-03-24',
permanentDeletionDate: '2024-03-31',
@ -53,6 +59,14 @@ describe('renderDeleteSuccessToast', () => {
});
});
describe('renderLeaveSuccessToast', () => {
it('calls toast correctly', () => {
renderLeaveSuccessToast(MOCK_GROUP);
expect(toast).toHaveBeenCalledWith(`Left the '${MOCK_GROUP.fullName}' group successfully.`);
});
});
describe('deleteParams', () => {
it('when delayed deletion is disabled, returns an empty object', () => {
const res = deleteParams(MOCK_GROUP_NO_DELAY_DELETION);

View File

@ -43,24 +43,24 @@ describe('ListActions', () => {
text: 'Delete',
variant: 'danger',
action: expect.any(Function),
order: 3,
order: 4,
},
]);
});
it('allows adding custom actions', () => {
const ACTION_LEAVE = 'leave';
const ACTION_CUSTOM = 'custom';
createComponent({
propsData: {
actions: {
...defaultPropsData.actions,
[ACTION_LEAVE]: {
text: 'Leave project',
[ACTION_CUSTOM]: {
text: 'Custom',
action: () => {},
},
},
availableActions: [ACTION_EDIT, ACTION_LEAVE, ACTION_DELETE],
availableActions: [ACTION_EDIT, ACTION_CUSTOM, ACTION_DELETE],
},
});
@ -71,14 +71,14 @@ describe('ListActions', () => {
order: 1,
},
{
text: 'Leave project',
text: 'Custom',
action: expect.any(Function),
},
{
text: 'Delete',
variant: 'danger',
action: expect.any(Function),
order: 3,
order: 4,
},
]);
});
@ -111,7 +111,7 @@ describe('ListActions', () => {
text: 'Delete',
variant: 'danger',
action: expect.any(Function),
order: 3,
order: 4,
},
{
text: 'Edit',

View File

@ -67,6 +67,14 @@ describe('NestedGroupsProjectsListItem', () => {
expect(wrapper.emitted('load-children')).toEqual([[1]]);
});
});
describe('when NestedGroupsProjectsList emits refetch event', () => {
it('emits refetch event', () => {
findNestedGroupsProjectsList().vm.$emit('refetch');
expect(wrapper.emitted('refetch')).toEqual([[]]);
});
});
});
describe('when item does not have children', () => {

View File

@ -24,9 +24,14 @@ describe('NestedGroupsProjectsList', () => {
});
};
it('renders list with `NestedGroupsProjectsListItem` component', () => {
createComponent();
const findNestedGroupsProjectsListItem = () =>
wrapper.findComponent(NestedGroupsProjectsListItem);
beforeEach(() => {
createComponent();
});
it('renders list with `NestedGroupsProjectsListItem` component', () => {
const listItemWrappers = wrapper.findAllComponents(NestedGroupsProjectsListItem).wrappers;
const expectedProps = listItemWrappers.map((listItemWrapper) => listItemWrapper.props());
@ -41,11 +46,17 @@ describe('NestedGroupsProjectsList', () => {
describe('when `NestedGroupsProjectsListItem emits load-children event', () => {
it('emits load-children event', () => {
createComponent();
wrapper.findComponent(NestedGroupsProjectsListItem).vm.$emit('load-children', 1);
findNestedGroupsProjectsListItem().vm.$emit('load-children', 1);
expect(wrapper.emitted('load-children')).toEqual([[1]]);
});
});
describe('when NestedGroupsProjectsListItem emits refetch event', () => {
it('emits refetch event', () => {
findNestedGroupsProjectsListItem().vm.$emit('refetch');
expect(wrapper.emitted('refetch')).toEqual([[]]);
});
});
});

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