Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2021-02-17 09:09:36 +00:00
parent 3c97422b09
commit 839e879bcf
58 changed files with 1358 additions and 923 deletions

View File

@ -1,202 +0,0 @@
<script>
import {
GlDropdownItem,
GlDropdownDivider,
GlAvatarLabeled,
GlAvatarLink,
GlSearchBoxByType,
GlLoadingIcon,
} from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import { mapActions, mapGetters, mapState } from 'vuex';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import searchUsers from '~/boards/graphql/users_search.query.graphql';
import { __, n__ } from '~/locale';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
import getIssueParticipants from '~/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql';
export default {
noSearchDelay: 0,
searchDelay: 250,
i18n: {
unassigned: __('Unassigned'),
assignee: __('Assignee'),
assignees: __('Assignees'),
assignTo: __('Assign to'),
},
components: {
BoardEditableItem,
IssuableAssignees,
MultiSelectDropdown,
GlDropdownItem,
GlDropdownDivider,
GlAvatarLabeled,
GlAvatarLink,
GlSearchBoxByType,
GlLoadingIcon,
},
data() {
return {
search: '',
issueParticipants: [],
selected: [],
};
},
apollo: {
issueParticipants: {
query: getIssueParticipants,
variables() {
return {
id: `gid://gitlab/Issue/${this.activeIssue.iid}`,
};
},
update(data) {
return data.issue?.participants?.nodes || [];
},
},
searchUsers: {
query: searchUsers,
variables() {
return {
search: this.search,
};
},
update: (data) => data.users?.nodes || [],
skip() {
return this.isSearchEmpty;
},
debounce: 250,
},
},
computed: {
...mapGetters(['activeIssue']),
...mapState(['isSettingAssignees']),
participants() {
return this.isSearchEmpty ? this.issueParticipants : this.searchUsers;
},
assigneeText() {
return n__('Assignee', '%d Assignees', this.selected.length);
},
unSelectedFiltered() {
return (
this.participants?.filter(({ username }) => {
return !this.selectedUserNames.includes(username);
}) || []
);
},
selectedIsEmpty() {
return this.selected.length === 0;
},
selectedUserNames() {
return this.selected.map(({ username }) => username);
},
isSearchEmpty() {
return this.search === '';
},
currentUser() {
return gon?.current_username;
},
isLoading() {
return (
this.$apollo.queries.issueParticipants?.loading || this.$apollo.queries.searchUsers?.loading
);
},
},
created() {
this.selected = cloneDeep(this.activeIssue.assignees);
},
methods: {
...mapActions(['setAssignees']),
async assignSelf() {
const [currentUserObject] = await this.setAssignees(this.currentUser);
this.selectAssignee(currentUserObject);
},
clearSelected() {
this.selected = [];
},
selectAssignee(name) {
if (name === undefined) {
this.clearSelected();
return;
}
this.selected = this.selected.concat(name);
},
unselect(name) {
this.selected = this.selected.filter((user) => user.username !== name);
},
saveAssignees() {
this.setAssignees(this.selectedUserNames);
},
isChecked(id) {
return this.selectedUserNames.includes(id);
},
},
};
</script>
<template>
<board-editable-item :loading="isSettingAssignees" :title="assigneeText" @close="saveAssignees">
<template #collapsed>
<issuable-assignees :users="activeIssue.assignees" @assign-self="assignSelf" />
</template>
<template #default>
<multi-select-dropdown
class="w-100"
:text="$options.i18n.assignees"
:header-text="$options.i18n.assignTo"
>
<template #search>
<gl-search-box-by-type v-model.trim="search" />
</template>
<template #items>
<gl-loading-icon v-if="isLoading" size="lg" />
<template v-else>
<gl-dropdown-item
:is-checked="selectedIsEmpty"
data-testid="unassign"
class="mt-2"
@click="selectAssignee()"
>{{ $options.i18n.unassigned }}</gl-dropdown-item
>
<gl-dropdown-divider data-testid="unassign-divider" />
<gl-dropdown-item
v-for="item in selected"
:key="item.id"
:is-checked="isChecked(item.username)"
@click="unselect(item.username)"
>
<gl-avatar-link>
<gl-avatar-labeled
:size="32"
:label="item.name"
:sub-label="item.username"
:src="item.avatarUrl || item.avatar"
/>
</gl-avatar-link>
</gl-dropdown-item>
<gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" />
<gl-dropdown-item
v-for="unselectedUser in unSelectedFiltered"
:key="unselectedUser.id"
:data-testid="`item_${unselectedUser.name}`"
@click="selectAssignee(unselectedUser)"
>
<gl-avatar-link>
<gl-avatar-labeled
:size="32"
:label="unselectedUser.name"
:sub-label="unselectedUser.username"
:src="unselectedUser.avatarUrl || unselectedUser.avatar"
/>
</gl-avatar-link>
</gl-dropdown-item>
</template>
</template>
</multi-select-dropdown>
</template>
</board-editable-item>
</template>

View File

@ -7,7 +7,6 @@ import { GlLabel } from '@gitlab/ui';
import $ from 'jquery';
import Vue from 'vue';
import DueDateSelectors from '~/due_date_select';
import { deprecatedCreateFlash as Flash } from '~/flash';
import IssuableContext from '~/issuable_context';
import LabelsSelect from '~/labels_select';
import { isScopedLabel } from '~/lib/utils/common_utils';
@ -16,6 +15,7 @@ import MilestoneSelect from '~/milestone_select';
import Sidebar from '~/right_sidebar';
import AssigneeTitle from '~/sidebar/components/assignees/assignee_title.vue';
import Assignees from '~/sidebar/components/assignees/assignees.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import Subscriptions from '~/sidebar/components/subscriptions/subscriptions.vue';
import TimeTracker from '~/sidebar/components/time_tracking/time_tracker.vue';
import eventHub from '~/sidebar/event_hub';
@ -32,6 +32,7 @@ export default Vue.extend({
RemoveBtn,
Subscriptions,
TimeTracker,
SidebarAssigneesWidget,
},
props: {
currentUser: {
@ -78,12 +79,6 @@ export default Vue.extend({
detail: {
handler() {
if (this.issue.id !== this.detail.issue.id) {
$('.block.assignee')
.find('input:not(.js-vue)[name="issue[assignee_ids][]"]')
.each((i, el) => {
$(el).remove();
});
$('.js-issue-board-sidebar', this.$el).each((i, el) => {
$(el).data('deprecatedJQueryDropdown').clearMenu();
});
@ -96,18 +91,9 @@ export default Vue.extend({
},
},
created() {
// Get events from deprecatedJQueryDropdown
eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
eventHub.$on('sidebar.addAssignee', this.addAssignee);
eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
eventHub.$on('sidebar.closeAll', this.closeSidebar);
},
beforeDestroy() {
eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
eventHub.$off('sidebar.addAssignee', this.addAssignee);
eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
eventHub.$off('sidebar.closeAll', this.closeSidebar);
},
mounted() {
@ -121,34 +107,8 @@ export default Vue.extend({
closeSidebar() {
this.detail.issue = {};
},
assignSelf() {
// Notify gl dropdown that we are now assigning to current user
this.$refs.assigneeBlock.dispatchEvent(new Event('assignYourself'));
this.addAssignee(this.currentUser);
this.saveAssignees();
},
removeAssignee(a) {
boardsStore.detail.issue.removeAssignee(a);
},
addAssignee(a) {
boardsStore.detail.issue.addAssignee(a);
},
removeAllAssignees() {
boardsStore.detail.issue.removeAllAssignees();
},
saveAssignees() {
this.loadingAssignees = true;
boardsStore.detail.issue
.update()
.then(() => {
this.loadingAssignees = false;
})
.catch(() => {
this.loadingAssignees = false;
Flash(__('An error occurred while saving assignees'));
});
setAssignees(data) {
boardsStore.detail.issue.setAssignees(data.issueSetAssignees.issue.assignees.nodes);
},
showScopedLabels(label) {
return boardsStore.scopedLabels.enabled && isScopedLabel(label);

View File

@ -86,7 +86,7 @@ export default () => {
groupId: Number($boardApp.dataset.groupId),
rootPath: $boardApp.dataset.rootPath,
currentUserId: gon.current_user_id || null,
canUpdate: $boardApp.dataset.canUpdate,
canUpdate: parseBoolean($boardApp.dataset.canUpdate),
labelsFetchPath: $boardApp.dataset.labelsFetchPath,
labelsManagePath: $boardApp.dataset.labelsManagePath,
labelsFilterBasePath: $boardApp.dataset.labelsFilterBasePath,

View File

@ -53,6 +53,10 @@ class ListIssue {
return boardsStore.findIssueAssignee(this, findAssignee);
}
setAssignees(assignees) {
boardsStore.setIssueAssignees(this, assignees);
}
removeAssignee(removeAssignee) {
boardsStore.removeIssueAssignee(this, removeAssignee);
}

View File

@ -1,13 +1,9 @@
import { pick } from 'lodash';
import boardListsQuery from 'ee_else_ce/boards/graphql/board_lists.query.graphql';
import { BoardType, ListType, inactiveId, flashAnimationDuration } from '~/boards/constants';
import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import createGqClient, { fetchPolicies } from '~/lib/graphql';
import { convertObjectPropsToCamelCase, urlParamsToObject } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
import {
formatBoardLists,
formatListIssues,
@ -333,34 +329,11 @@ export default {
},
setAssignees: ({ commit, getters }, assigneeUsernames) => {
commit(types.SET_ASSIGNEE_LOADING, true);
return gqlClient
.mutate({
mutation: updateAssigneesMutation,
variables: {
iid: getters.activeIssue.iid,
projectPath: getters.activeIssue.referencePath.split('#')[0],
assigneeUsernames,
},
})
.then(({ data }) => {
const { nodes } = data.issueSetAssignees?.issue?.assignees || [];
commit('UPDATE_ISSUE_BY_ID', {
issueId: getters.activeIssue.id,
prop: 'assignees',
value: nodes,
});
return nodes;
})
.catch(() => {
createFlash({ message: __('An error occurred while updating assignees.') });
})
.finally(() => {
commit(types.SET_ASSIGNEE_LOADING, false);
});
commit('UPDATE_ISSUE_BY_ID', {
issueId: getters.activeIssue.id,
prop: 'assignees',
value: assigneeUsernames,
});
},
setActiveIssueMilestone: async ({ commit, getters }, input) => {

View File

@ -724,6 +724,10 @@ const boardsStore = {
}
},
setIssueAssignees(issue, assignees) {
issue.assignees = [...assignees];
},
removeIssueLabels(issue, labels) {
labels.forEach(issue.removeLabel.bind(issue));
},

View File

@ -347,15 +347,20 @@ export default {
>
<header
v-if="showToolbar"
class="row-content-block gl-border-t-0 gl-p-3 gl-display-flex"
class="row-content-block gl-border-t-0 gl-py-3 gl-display-flex"
data-testid="design-toolbar-wrapper"
>
<div class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full">
<div>
<div
class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full gl-flex-wrap"
>
<div class="gl-display-flex gl-align-items-center gl-my-2">
<span class="gl-font-weight-bold gl-mr-3">{{ s__('DesignManagement|Designs') }}</span>
<design-version-dropdown />
</div>
<div v-show="hasDesigns" class="qa-selector-toolbar gl-display-flex gl-align-items-center">
<div
v-show="hasDesigns"
class="qa-selector-toolbar gl-display-flex gl-align-items-center gl-my-2"
>
<gl-button
v-if="isLatestVersion"
variant="link"

View File

@ -3,6 +3,7 @@
query getProjectIssue($iid: String!, $fullPath: ID!) {
project(fullPath: $fullPath) {
issue(iid: $iid) {
id
assignees {
nodes {
...Author

View File

@ -11,10 +11,6 @@ export default {
UncollapsedAssigneeList,
},
props: {
rootPath: {
type: String,
required: true,
},
users: {
type: Array,
required: true,
@ -51,9 +47,9 @@ export default {
<div>
<collapsed-assignee-list :users="sortedAssigness" :issuable-type="issuableType" />
<div class="value hide-collapsed">
<div data-testid="expanded-assignee" class="value hide-collapsed">
<template v-if="hasNoUsers">
<span class="assign-yourself no-value qa-assign-yourself">
<span class="assign-yourself no-value">
{{ __('None') }}
<template v-if="editable">
-
@ -64,12 +60,7 @@ export default {
</span>
</template>
<uncollapsed-assignee-list
v-else
:users="sortedAssigness"
:root-path="rootPath"
:issuable-type="issuableType"
/>
<uncollapsed-assignee-list v-else :users="sortedAssigness" :issuable-type="issuableType" />
</div>
</div>
</template>

View File

@ -8,12 +8,16 @@ export default {
GlButton,
UncollapsedAssigneeList,
},
inject: ['rootPath'],
props: {
users: {
type: Array,
required: true,
},
issuableType: {
type: String,
required: false,
default: 'issue',
},
},
computed: {
assigneesText() {
@ -36,9 +40,9 @@ export default {
variant="link"
@click="$emit('assign-self')"
>
<span class="gl-text-gray-400">{{ __('assign yourself') }}</span>
<span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span>
</gl-button>
</div>
<uncollapsed-assignee-list v-else :users="users" :root-path="rootPath" />
<uncollapsed-assignee-list v-else :users="users" :issuable-type="issuableType" />
</div>
</template>

View File

@ -44,6 +44,11 @@ export default {
type: String,
required: true,
},
assigneeAvailabilityStatus: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
@ -101,6 +106,13 @@ export default {
return new Flash(__('Error occurred when saving assignees'));
});
},
exposeAvailabilityStatus(users) {
return users.map(({ username, ...rest }) => ({
...rest,
username,
availability: this.assigneeAvailabilityStatus[username] || '',
}));
},
},
};
</script>
@ -123,7 +135,7 @@ export default {
<assignees
v-if="!store.isFetching.assignees"
:root-path="relativeUrlRoot"
:users="store.assignees"
:users="exposeAvailabilityStatus(store.assignees)"
:editable="store.editable"
:issuable-type="issuableType"
class="value"

View File

@ -0,0 +1,403 @@
<script>
import {
GlDropdownItem,
GlDropdownDivider,
GlAvatarLabeled,
GlAvatarLink,
GlSearchBoxByType,
GlLoadingIcon,
} from '@gitlab/ui';
import { cloneDeep } from 'lodash';
import Vue from 'vue';
import createFlash from '~/flash';
import searchUsers from '~/graphql_shared/queries/users_search.query.graphql';
import { IssuableType } from '~/issue_show/constants';
import { __, n__ } from '~/locale';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { assigneesQueries } from '~/sidebar/constants';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
export const assigneesWidget = Vue.observable({
updateAssignees: null,
});
export default {
i18n: {
unassigned: __('Unassigned'),
assignee: __('Assignee'),
assignees: __('Assignees'),
assignTo: __('Assign to'),
},
assigneesQueries,
components: {
SidebarEditableItem,
IssuableAssignees,
MultiSelectDropdown,
GlDropdownItem,
GlDropdownDivider,
GlAvatarLabeled,
GlAvatarLink,
GlSearchBoxByType,
GlLoadingIcon,
},
props: {
iid: {
type: String,
required: true,
},
fullPath: {
type: String,
required: true,
},
initialAssignees: {
type: Array,
required: false,
default: null,
},
issuableType: {
type: String,
required: false,
default: IssuableType.Issue,
validator(value) {
return [IssuableType.Issue, IssuableType.MergeRequest].includes(value);
},
},
multipleAssignees: {
type: Boolean,
required: false,
default: true,
},
},
data() {
return {
search: '',
issuable: {},
searchUsers: [],
selected: [],
isSettingAssignees: false,
isSearching: false,
};
},
apollo: {
issuable: {
query() {
return this.$options.assigneesQueries[this.issuableType].query;
},
variables() {
return this.queryVariables;
},
update(data) {
return data.issuable || data.project?.issuable;
},
result({ data }) {
const issuable = data.issuable || data.project?.issuable;
if (issuable) {
this.selected = this.moveCurrentUserToStart(cloneDeep(issuable.assignees.nodes));
}
},
error() {
createFlash({ message: __('An error occurred while fetching participants.') });
},
},
searchUsers: {
query: searchUsers,
variables() {
return {
search: this.search,
};
},
update(data) {
return data.users?.nodes || [];
},
debounce: 250,
skip() {
return this.isSearchEmpty;
},
error() {
createFlash({ message: __('An error occurred while searching users.') });
this.isSearching = false;
},
result() {
this.isSearching = false;
},
},
},
computed: {
queryVariables() {
return {
iid: this.iid,
fullPath: this.fullPath,
};
},
assignees() {
const currentAssignees = this.$apollo.queries.issuable.loading
? this.initialAssignees
: this.issuable?.assignees?.nodes;
return currentAssignees || [];
},
participants() {
const users =
this.isSearchEmpty || this.isSearching
? this.issuable?.participants?.nodes
: this.searchUsers;
return this.moveCurrentUserToStart(users);
},
assigneeText() {
const items = this.$apollo.queries.issuable.loading ? this.initialAssignees : this.selected;
return n__('Assignee', '%d Assignees', items.length);
},
selectedFiltered() {
if (this.isSearchEmpty || this.isSearching) {
return this.selected;
}
const foundUsernames = this.searchUsers.map(({ username }) => username);
return this.selected.filter(({ username }) => foundUsernames.includes(username));
},
unselectedFiltered() {
return (
this.participants?.filter(({ username }) => !this.selectedUserNames.includes(username)) ||
[]
);
},
selectedIsEmpty() {
return this.selectedFiltered.length === 0;
},
selectedUserNames() {
return this.selected.map(({ username }) => username);
},
isSearchEmpty() {
return this.search === '';
},
currentUser() {
return {
username: gon?.current_username,
name: gon?.current_user_fullname,
avatarUrl: gon?.current_user_avatar_url,
};
},
isAssigneesLoading() {
return !this.initialAssignees && this.$apollo.queries.issuable.loading;
},
isCurrentUserInParticipants() {
const isCurrentUser = (user) => user.username === this.currentUser.username;
return this.selected.some(isCurrentUser) || this.participants.some(isCurrentUser);
},
noUsersFound() {
return !this.isSearchEmpty && this.unselectedFiltered.length === 0;
},
showCurrentUser() {
return !this.isCurrentUserInParticipants && (this.isSearchEmpty || this.isSearching);
},
},
watch: {
// We need to add this watcher to track the moment when user is alredy typing
// but query is still not started due to debounce
search(newVal) {
if (newVal) {
this.isSearching = true;
}
},
},
created() {
assigneesWidget.updateAssignees = this.updateAssignees;
},
destroyed() {
assigneesWidget.updateAssignees = null;
},
methods: {
updateAssignees(assigneeUsernames) {
this.isSettingAssignees = true;
return this.$apollo
.mutate({
mutation: this.$options.assigneesQueries[this.issuableType].mutation,
variables: {
...this.queryVariables,
assigneeUsernames,
},
})
.then(({ data }) => {
this.$emit('assignees-updated', data);
return data;
})
.catch(() => {
createFlash({ message: __('An error occurred while updating assignees.') });
})
.finally(() => {
this.isSettingAssignees = false;
});
},
selectAssignee(name) {
if (name === undefined) {
this.clearSelected();
return;
}
if (!this.multipleAssignees) {
this.selected = [name];
this.collapseWidget();
} else {
this.selected = this.selected.concat(name);
}
},
unselect(name) {
this.selected = this.selected.filter((user) => user.username !== name);
if (!this.multipleAssignees) {
this.collapseWidget();
}
},
assignSelf() {
this.updateAssignees(this.currentUser.username);
},
clearSelected() {
this.selected = [];
},
saveAssignees() {
this.updateAssignees(this.selectedUserNames);
},
isChecked(id) {
return this.selectedUserNames.includes(id);
},
async focusSearch() {
await this.$nextTick();
this.$refs.search.focusInput();
},
moveCurrentUserToStart(users) {
if (!users) {
return [];
}
const usersCopy = [...users];
const currentUser = usersCopy.find((user) => user.username === this.currentUser.username);
if (currentUser) {
const index = usersCopy.indexOf(currentUser);
usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]);
}
return usersCopy;
},
collapseWidget() {
this.$refs.toggle.collapse();
},
},
};
</script>
<template>
<div
v-if="isAssigneesLoading"
class="gl-display-flex gl-align-items-center assignee"
data-testid="loading-assignees"
>
{{ __('Assignee') }}
<gl-loading-icon size="sm" class="gl-ml-2" />
</div>
<sidebar-editable-item
v-else
ref="toggle"
:loading="isSettingAssignees"
:title="assigneeText"
@open="focusSearch"
@close="saveAssignees"
>
<template #collapsed>
<issuable-assignees
:users="assignees"
:issuable-type="issuableType"
@assign-self="assignSelf"
/>
</template>
<template #default>
<multi-select-dropdown
class="gl-w-full dropdown-menu-user"
:text="$options.i18n.assignees"
:header-text="$options.i18n.assignTo"
@toggle="collapseWidget"
>
<template #search>
<gl-search-box-by-type ref="search" v-model.trim="search" />
</template>
<template #items>
<gl-loading-icon
v-if="$apollo.queries.searchUsers.loading || $apollo.queries.issuable.loading"
data-testid="loading-participants"
size="lg"
/>
<template v-else>
<template v-if="isSearchEmpty || isSearching">
<gl-dropdown-item
:is-checked="selectedIsEmpty"
:is-check-centered="true"
data-testid="unassign"
@click="selectAssignee()"
>
<span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'">{{
$options.i18n.unassigned
}}</span></gl-dropdown-item
>
<gl-dropdown-divider data-testid="unassign-divider" />
</template>
<gl-dropdown-item
v-for="item in selectedFiltered"
:key="item.id"
:is-checked="isChecked(item.username)"
:is-check-centered="true"
data-testid="selected-participant"
@click.stop="unselect(item.username)"
>
<gl-avatar-link>
<gl-avatar-labeled
:size="32"
:label="item.name"
:sub-label="item.username"
:src="item.avatarUrl || item.avatar || item.avatar_url"
class="gl-align-items-center"
/>
</gl-avatar-link>
</gl-dropdown-item>
<gl-dropdown-divider v-if="!selectedIsEmpty" data-testid="selected-user-divider" />
<template v-if="showCurrentUser">
<gl-dropdown-item
data-testid="unselected-participant"
@click.stop="selectAssignee(currentUser)"
>
<gl-avatar-link>
<gl-avatar-labeled
:size="32"
:label="currentUser.name"
:sub-label="currentUser.username"
:src="currentUser.avatarUrl"
class="gl-align-items-center"
/>
</gl-avatar-link>
</gl-dropdown-item>
<gl-dropdown-divider />
</template>
<gl-dropdown-item
v-for="unselectedUser in unselectedFiltered"
:key="unselectedUser.id"
data-testid="unselected-participant"
@click="selectAssignee(unselectedUser)"
>
<gl-avatar-link class="gl-pl-6!">
<gl-avatar-labeled
:size="32"
:label="unselectedUser.name"
:sub-label="unselectedUser.username"
:src="unselectedUser.avatarUrl || unselectedUser.avatar"
class="gl-align-items-center"
/>
</gl-avatar-link>
</gl-dropdown-item>
<gl-dropdown-item v-if="noUsersFound && !isSearching">
{{ __('No matching results') }}
</gl-dropdown-item>
</template>
</template>
</multi-select-dropdown>
</template>
</sidebar-editable-item>
</template>

View File

@ -59,7 +59,7 @@ export default {
<div class="value hide-collapsed">
<template v-if="hasNoUsers">
<span class="assign-yourself no-value qa-assign-yourself">
<span class="assign-yourself no-value">
{{ __('None') }}
</span>
</template>

View File

@ -0,0 +1,95 @@
<script>
import { GlButton, GlLoadingIcon } from '@gitlab/ui';
export default {
components: { GlButton, GlLoadingIcon },
inject: ['canUpdate'],
props: {
title: {
type: String,
required: false,
default: '',
},
loading: {
type: Boolean,
required: false,
default: false,
},
},
data() {
return {
edit: false,
};
},
destroyed() {
window.removeEventListener('click', this.collapseWhenOffClick);
window.removeEventListener('keyup', this.collapseOnEscape);
},
methods: {
collapseWhenOffClick({ target }) {
if (!this.$el.contains(target)) {
this.collapse();
}
},
collapseOnEscape({ key }) {
if (key === 'Escape') {
this.collapse();
}
},
expand() {
if (this.edit) {
return;
}
this.edit = true;
this.$emit('open');
window.addEventListener('click', this.collapseWhenOffClick);
window.addEventListener('keyup', this.collapseOnEscape);
},
collapse({ emitEvent = true } = {}) {
if (!this.edit) {
return;
}
this.edit = false;
if (emitEvent) {
this.$emit('close');
}
window.removeEventListener('click', this.collapseWhenOffClick);
window.removeEventListener('keyup', this.collapseOnEscape);
},
toggle({ emitEvent = true } = {}) {
if (this.edit) {
this.collapse({ emitEvent });
} else {
this.expand();
}
},
},
};
</script>
<template>
<div>
<div class="gl-display-flex gl-align-items-center gl-mb-3" @click.self="collapse">
<span data-testid="title">{{ title }}</span>
<gl-loading-icon v-if="loading" inline class="gl-ml-2" />
<gl-button
v-if="canUpdate"
variant="link"
class="gl-text-gray-900! gl-hover-text-blue-800! gl-ml-auto js-sidebar-dropdown-toggle"
data-testid="edit-button"
@keyup.esc="toggle"
@click="toggle"
>
{{ __('Edit') }}
</gl-button>
</div>
<div v-show="!edit" class="gl-text-gray-500" data-testid="collapsed-content">
<slot name="collapsed">{{ __('None') }}</slot>
</div>
<div v-show="edit" data-testid="expanded-content">
<slot :edit="edit"></slot>
</div>
</div>
</template>

View File

@ -0,0 +1,16 @@
import { IssuableType } from '~/issue_show/constants';
import getIssueParticipants from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql';
import updateAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql';
import updateMergeRequestParticipantsMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql';
export const assigneesQueries = {
[IssuableType.Issue]: {
query: getIssueParticipants,
mutation: updateAssigneesMutation,
},
[IssuableType.MergeRequest]: {
query: getMergeRequestParticipants,
mutation: updateMergeRequestParticipantsMutation,
},
};

View File

@ -30,6 +30,28 @@ function getSidebarOptions(sidebarOptEl = document.querySelector('.js-sidebar-op
return JSON.parse(sidebarOptEl.innerHTML);
}
/**
* Extracts the list of assignees with availability information from a hidden input
* field and converts to a key:value pair for use in the sidebar assignees component.
* The assignee username is used as the key and their busy status is the value
*
* e.g { root: 'busy', admin: '' }
*
* @returns {Object}
*/
function getSidebarAssigneeAvailabilityData() {
const sidebarAssigneeEl = document.querySelectorAll('.js-sidebar-assignee-data input');
return Array.from(sidebarAssigneeEl)
.map((el) => el.dataset)
.reduce(
(acc, { username, availability = '' }) => ({
...acc,
[username]: availability,
}),
{},
);
}
function mountAssigneesComponent(mediator) {
const el = document.getElementById('js-vue-sidebar-assignees');
const apolloProvider = new VueApollo({
@ -39,6 +61,7 @@ function mountAssigneesComponent(mediator) {
if (!el) return;
const { iid, fullPath } = getSidebarOptions();
const assigneeAvailabilityStatus = getSidebarAssigneeAvailabilityData();
// eslint-disable-next-line no-new
new Vue({
el,
@ -56,6 +79,7 @@ function mountAssigneesComponent(mediator) {
signedIn: el.hasAttribute('data-signed-in'),
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage() ? 'issue' : 'merge_request',
assigneeAvailabilityStatus,
},
}),
});

View File

@ -9,6 +9,7 @@ import {
AJAX_USERS_SELECT_PARAMS_MAP,
} from 'ee_else_ce/users_select/constants';
import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown';
import { isUserBusy } from '~/set_status_modal/utils';
import { fixTitle, dispose } from '~/tooltips';
import ModalStore from '../boards/stores/modal_store';
import axios from '../lib/utils/axios_utils';
@ -795,13 +796,17 @@ UsersSelect.prototype.renderRow = function (
? `data-container="body" data-placement="left" data-title="${tooltip}"`
: '';
const name =
user?.availability && isUserBusy(user.availability)
? sprintf(__('%{name} (Busy)'), { name: user.name })
: user.name;
return `
<li data-user-id=${user.id}>
<a href="#" class="dropdown-menu-user-link d-flex align-items-center ${linkClasses}" ${tooltipAttributes}>
${this.renderRowAvatar(issuableType, user, img)}
<span class="d-flex flex-column overflow-hidden">
<strong class="dropdown-menu-user-full-name gl-font-weight-bold">
${escape(user.name)}
${escape(name)}
</strong>
${
username

View File

@ -20,7 +20,7 @@ export default {
</script>
<template>
<gl-dropdown class="show" :text="text" :header-text="headerText">
<gl-dropdown class="show" :text="text" :header-text="headerText" @toggle="$emit('toggle')">
<slot name="search"></slot>
<gl-dropdown-form>
<slot name="items"></slot>

View File

@ -1,13 +0,0 @@
query issueParticipants($id: IssueID!) {
issue(id: $id) {
participants {
nodes {
username
name
webUrl
avatarUrl
id
}
}
}
}

View File

@ -0,0 +1,19 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
query issueParticipants($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
issuable: issue(iid: $iid) {
id
participants {
nodes {
...User
}
}
assignees {
nodes {
...User
}
}
}
}
}

View File

@ -0,0 +1,19 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
query getMrParticipants($fullPath: ID!, $iid: String!) {
project(fullPath: $fullPath) {
issuable: mergeRequest(iid: $iid) {
id
participants {
nodes {
...User
}
}
assignees {
nodes {
...User
}
}
}
}
}

View File

@ -1,15 +1,19 @@
mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $projectPath: ID!) {
#import "~/graphql_shared/fragments/user.fragment.graphql"
mutation issueSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
issueSetAssignees(
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $projectPath }
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath }
) {
issue {
id
assignees {
nodes {
username
id
name
webUrl
avatarUrl
...User
}
}
participants {
nodes {
...User
}
}
}

View File

@ -0,0 +1,21 @@
#import "~/graphql_shared/fragments/user.fragment.graphql"
mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) {
mergeRequestSetAssignees(
input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath }
) {
mergeRequest {
id
assignees {
nodes {
...User
}
}
participants {
nodes {
...User
}
}
}
}
}

View File

@ -14,7 +14,7 @@ class Projects::ServicesController < Projects::ApplicationController
before_action only: :edit do
push_frontend_feature_flag(:jira_issues_integration, @project, type: :licensed, default_enabled: true)
push_frontend_feature_flag(:jira_vulnerabilities_integration, @project, type: :licensed, default_enabled: true)
push_frontend_feature_flag(:jira_for_vulnerabilities, @project, type: :development, default_enabled: false)
push_frontend_feature_flag(:jira_for_vulnerabilities, @project, type: :development, default_enabled: :yaml)
end
respond_to :html

View File

@ -100,23 +100,6 @@ module BoardsHelper
}
end
def board_sidebar_user_data
dropdown_options = assignees_dropdown_options('issue')
{
toggle: 'dropdown',
field_name: 'issue[assignee_ids][]',
first_user: current_user&.username,
current_user: 'true',
project_id: @project&.id,
group_id: @group&.id,
null_user: 'true',
multi_select: 'true',
'dropdown-header': dropdown_options[:data][:'dropdown-header'],
'max-select': dropdown_options[:data][:'max-select']
}
end
def boards_link_text
if current_board_parent.multiple_issue_boards_available?
s_("IssueBoards|Boards")

View File

@ -9,6 +9,8 @@ module MergeRequests
class PostMergeService < MergeRequests::BaseService
include RemovesRefs
MAX_RETARGET_MERGE_REQUESTS = 4
def execute(merge_request)
merge_request.mark_as_merged
close_issues(merge_request)
@ -18,6 +20,7 @@ module MergeRequests
merge_request_activity_counter.track_merge_mr_action(user: current_user)
notification_service.merge_mr(merge_request, current_user)
execute_hooks(merge_request, 'merge')
retarget_chain_merge_requests(merge_request)
invalidate_cache_counts(merge_request, users: merge_request.assignees | merge_request.reviewers)
merge_request.update_project_counter_caches
delete_non_latest_diffs(merge_request)
@ -28,6 +31,34 @@ module MergeRequests
private
def retarget_chain_merge_requests(merge_request)
return unless Feature.enabled?(:retarget_merge_requests, merge_request.target_project)
# we can only retarget MRs that are targeting the same project
# and have a remove source branch set
return unless merge_request.for_same_project? && merge_request.remove_source_branch?
# find another merge requests that
# - as a target have a current source project and branch
other_merge_requests = merge_request.source_project
.merge_requests
.opened
.by_target_branch(merge_request.source_branch)
.preload_source_project
.at_most(MAX_RETARGET_MERGE_REQUESTS)
other_merge_requests.find_each do |other_merge_request|
# Update only MRs on projects that we have access to
next unless can?(current_user, :update_merge_request, other_merge_request.source_project)
::MergeRequests::UpdateService
.new(other_merge_request.source_project, current_user,
target_branch: merge_request.target_branch,
target_branch_was_deleted: true)
.execute(other_merge_request)
end
end
def close_issues(merge_request)
return unless merge_request.target_branch == project.default_branch

View File

@ -4,6 +4,12 @@ module MergeRequests
class UpdateService < MergeRequests::BaseService
extend ::Gitlab::Utils::Override
def initialize(project, user = nil, params = {})
super
@target_branch_was_deleted = @params.delete(:target_branch_was_deleted)
end
def execute(merge_request)
# We don't allow change of source/target projects and source branch
# after merge request was created
@ -36,7 +42,9 @@ module MergeRequests
end
if merge_request.previous_changes.include?('target_branch')
create_branch_change_note(merge_request, 'target',
create_branch_change_note(merge_request,
'target',
target_branch_was_deleted ? 'delete' : 'update',
merge_request.previous_changes['target_branch'].first,
merge_request.target_branch)
@ -130,6 +138,8 @@ module MergeRequests
private
attr_reader :target_branch_was_deleted
def handle_milestone_change(merge_request)
return if skip_milestone_email
@ -162,9 +172,9 @@ module MergeRequests
merge_request_activity_counter.track_users_review_requested(users: new_reviewers)
end
def create_branch_change_note(issuable, branch_type, old_branch, new_branch)
def create_branch_change_note(issuable, branch_type, event_type, old_branch, new_branch)
SystemNoteService.change_branch(
issuable, issuable.project, current_user, branch_type,
issuable, issuable.project, current_user, branch_type, event_type,
old_branch, new_branch)
end

View File

@ -168,16 +168,19 @@ module SystemNoteService
# project - Project owning noteable
# author - User performing the change
# branch_type - 'source' or 'target'
# event_type - the source of event: 'update' or 'delete'
# old_branch - old branch name
# new_branch - new branch name
#
# Example Note text:
# Example Note text is based on event_type:
#
# "changed target branch from `Old` to `New`"
# update: "changed target branch from `Old` to `New`"
# delete: "changed automatically target branch to `New` because `Old` was deleted"
#
# Returns the created Note object
def change_branch(noteable, project, author, branch_type, old_branch, new_branch)
::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author).change_branch(branch_type, old_branch, new_branch)
def change_branch(noteable, project, author, branch_type, event_type, old_branch, new_branch)
::SystemNotes::MergeRequestsService.new(noteable: noteable, project: project, author: author)
.change_branch(branch_type, event_type, old_branch, new_branch)
end
# Called when a branch in Noteable is added or deleted

View File

@ -83,16 +83,26 @@ module SystemNotes
# Called when a branch in Noteable is changed
#
# branch_type - 'source' or 'target'
# event_type - the source of event: 'update' or 'delete'
# old_branch - old branch name
# new_branch - new branch name
# Example Note text is based on event_type:
#
# Example Note text:
#
# "changed target branch from `Old` to `New`"
# update: "changed target branch from `Old` to `New`"
# delete: "changed automatically target branch to `New` because `Old` was deleted"
#
# Returns the created Note object
def change_branch(branch_type, old_branch, new_branch)
body = "changed #{branch_type} branch from `#{old_branch}` to `#{new_branch}`"
def change_branch(branch_type, event_type, old_branch, new_branch)
body =
case event_type.to_s
when 'delete'
"changed automatically #{branch_type} branch to `#{new_branch}` because `#{old_branch}` was deleted"
when 'update'
"changed #{branch_type} branch from `#{old_branch}` to `#{new_branch}`"
else
raise ArgumentError, "invalid value for event_type: #{event_type}"
end
create_note(NoteSummary.new(noteable, project, author, body, action: 'branch'))
end

View File

@ -1,31 +1,9 @@
- dropdown_options = assignees_dropdown_options('issue')
.block.assignee{ ref: "assigneeBlock" }
%template{ "v-if" => "issue.assignees" }
%assignee-title{ ":number-of-assignees" => "issue.assignees.length",
":loading" => "loadingAssignees",
":editable" => can_admin_issue? }
%assignees.value{ "root-path" => "#{root_url}",
":users" => "issue.assignees",
":editable" => can_admin_issue?,
"@assign-self" => "assignSelf" }
- if can_admin_issue?
.selectbox.hide-collapsed
%input.js-vue{ type: "hidden",
name: "issue[assignee_ids][]",
":value" => "assignee.id",
"v-if" => "issue.assignees",
"v-for" => "assignee in issue.assignees",
":data-avatar_url" => "assignee.avatar",
":data-name" => "assignee.name",
":data-username" => "assignee.username" }
.dropdown
- dropdown_options = assignees_dropdown_options('issue')
%button.dropdown-menu-toggle.js-user-search.js-author-search.js-multiselect.js-save-user-data.js-issue-board-sidebar{ type: 'button', ref: 'assigneeDropdown', data: board_sidebar_user_data,
":data-issuable-id" => "issue.iid" }
= dropdown_options[:title]
= sprite_icon('chevron-down', css_class: "dropdown-menu-toggle-icon gl-top-3")
.dropdown-menu.dropdown-select.dropdown-menu-user.dropdown-menu-selectable.dropdown-menu-author
= dropdown_title("Assign to")
= dropdown_filter("Search users")
= dropdown_content
= dropdown_loading
%sidebar-assignees-widget{ ":iid" => "String(issue.iid)",
":full-path" => "issue.path.split('/-/')[0].substring(1)",
":initial-assignees" => "issue.assignees",
":multiple-assignees" => "!Boolean(#{dropdown_options[:data][:"max-select"]})",
"@assignees-updated" => "setAssignees" }

View File

@ -5,7 +5,7 @@
= _('Assignee')
= loading_icon(css_class: 'gl-vertical-align-text-bottom')
.selectbox.hide-collapsed
.js-sidebar-assignee-data.selectbox.hide-collapsed
- if assignees.none?
= hidden_field_tag "#{issuable_type}[assignee_ids][]", 0, id: nil
- else

View File

@ -0,0 +1,5 @@
---
title: Display user busy status in issue sidebar
merge_request: 54165
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Create new assignees widget for boards
merge_request: 50054
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Fix overflowing design buttons on mobile
merge_request: 54381
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Automatically retarget merge requests
merge_request: 53710
author:
type: added

View File

@ -4,4 +4,4 @@ introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46982
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/276893
type: development
group: group::threat insights
default_enabled: false
default_enabled: true

View File

@ -0,0 +1,8 @@
---
name: retarget_merge_requests
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/53710
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/320895
milestone: '13.9'
type: development
group: group::memory
default_enabled: false

View File

@ -488,6 +488,10 @@ resync
resynced
resyncing
resyncs
retarget
retargeted
retargeting
retargets
reusability
reverified
reverifies

View File

@ -626,3 +626,11 @@ Set the limit to `0` to allow any file size.
### Package versions returned
When asking for versions of a given NuGet package name, the GitLab Package Registry returns a maximum of 300 versions.
## Branch retargeting on merge **(FREE SELF)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/320902) in GitLab 13.9.
If a branch is merged while open merge requests still point to it, GitLab can
retarget merge requests pointing to the now-merged branch. To learn more, read
[Branch retargeting on merge](../user/project/merge_requests/getting_started.md#branch-retargeting-on-merge).

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.7 KiB

View File

@ -141,12 +141,12 @@ reports are available to download. To download a report, click on the
> Introduced in [GitLab Ultimate](https://about.gitlab.com/pricing/) 10.8.
Each security vulnerability in the merge request report or the
[Security Dashboard](security_dashboard/index.md) is actionable. Click an entry to view detailed
[Vulnerability Report](vulnerability_report/index.md) is actionable. Click an entry to view detailed
information with several options:
- [Dismiss vulnerability](#dismissing-a-vulnerability): Dismissing a vulnerability styles it in
strikethrough.
- [Create issue](#creating-an-issue-for-a-vulnerability): Create a new issue with the title and
- [Create issue](vulnerabilities/index.md#create-a-gitlab-issue-for-a-vulnerability): Create a new issue with the title and
description pre-populated with information from the vulnerability report. By default, such issues
are [confidential](../project/issues/confidential_issues.md).
- [Automatic Remediation](#automatic-remediation-for-vulnerabilities): For some vulnerabilities,
@ -265,29 +265,18 @@ Pressing the "Dismiss Selected" button dismisses all the selected vulnerabilitie
![Multiple vulnerability dismissal](img/multi_select_v12_9.png)
### Creating an issue for a vulnerability
### Create an issue for a vulnerability
You can create an issue for a vulnerability by visiting the vulnerability's page and clicking
**Create issue**, which you can find in the **Related issues** section.
![Create issue from vulnerability](img/create_issue_from_vulnerability_v13_3.png)
This creates a [confidential issue](../project/issues/confidential_issues.md) in the project the
vulnerability came from, and pre-populates it with some useful information taken from the vulnerability
report. After the issue is created, you are redirected to it so you can edit, assign, or comment on
it.
Upon returning to the group security dashboard, the vulnerability now has an associated issue next
to the name.
![Linked issue in the group security dashboard](img/issue.png)
You can create a GitLab issue, or a Jira issue (if it's enabled) for a vulnerability. For more
details, see [Vulnerability Pages](vulnerabilities/index.md).
### Automatic remediation for vulnerabilities
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/5656) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 11.7.
Some vulnerabilities can be fixed by applying the solution that GitLab
automatically generates. Although the feature name is Automatic Remediation, this feature is also commonly called Auto-Remediation, Auto Remediation, or Suggested Solutions. The following scanners are supported:
Some vulnerabilities can be fixed by applying the solution that GitLab automatically generates.
Although the feature name is Automatic Remediation, this feature is also commonly called
Auto-Remediation, Auto Remediation, or Suggested Solutions. The following scanners are supported:
- [Dependency Scanning](dependency_scanning/index.md):
Automatic Patch creation is only available for Node.js projects managed with

View File

@ -5,60 +5,107 @@ group: Threat Insights
info: To determine the technical writer assigned to the Stage/Group associated with this page, see https://about.gitlab.com/handbook/engineering/ux/technical-writing/#assignments
---
# Vulnerability Pages
# Vulnerability Pages **(ULTIMATE)**
> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/13561) in [GitLab Ultimate](https://about.gitlab.com/pricing/) 13.0.
Each security vulnerability in a project's [Security Dashboard](../security_dashboard/index.md#project-security-dashboard) has an individual page which includes:
Each security vulnerability in a project's [Vulnerability Report](../vulnerability_report/index.md) has an individual page which includes:
- Details for the vulnerability.
- Details of the vulnerability.
- The status of the vulnerability within the project.
- Available actions for the vulnerability.
- Any issues related to the vulnerability.
On the vulnerability page, you can interact with the vulnerability in
several different ways:
On the vulnerability's page, you can:
- [Change the Vulnerability Status](#changing-vulnerability-status) - You can change the
status of a vulnerability to **Detected**, **Confirmed**, **Dismissed**, or **Resolved**.
- [Create issue](#creating-an-issue-for-a-vulnerability) - Create a new issue with the
title and description pre-populated with information from the vulnerability report.
By default, such issues are [confidential](../../project/issues/confidential_issues.md).
- [Link issues](#link-issues-to-the-vulnerability) - Link existing issues to vulnerability.
- [Automatic remediation](#automatic-remediation-for-vulnerabilities) - For some vulnerabilities,
a solution is provided for how to fix the vulnerability automatically.
- [Change the vulnerability's status](#change-vulnerability-status).
- [Create a GitLab issue](#create-a-gitlab-issue-for-a-vulnerability).
- [Create a Jira issue](#create-a-jira-issue-for-a-vulnerability).
- [Link issues to the vulnerability](#link-gitlab-issues-to-the-vulnerability).
- [Automatically remediate the vulnerability](#automatically-remediate-the-vulnerability), if an
automatic solution is available.
## Changing vulnerability status
## Change vulnerability status
You can switch the status of a vulnerability using the **Status** dropdown to one of
You can change the status of a vulnerability using the **Status** dropdown to one of
the following values:
| Status | Description |
|-----------|------------------------------------------------------------------------------------------------------------------|
| Detected | The default state for a newly discovered vulnerability |
| Confirmed | A user has seen this vulnerability and confirmed it to be accurate |
| Status | Description |
|-----------|----------------------------------------------------------------------------------------------------------------|
| Detected | The default state for a newly discovered vulnerability |
| Confirmed | A user has seen this vulnerability and confirmed it to be accurate |
| Dismissed | A user has seen this vulnerability and dismissed it because it is not accurate or otherwise not to be resolved |
| Resolved | The vulnerability has been fixed and is no longer valid |
| Resolved | The vulnerability has been fixed and is no longer valid |
A timeline shows you when the vulnerability status has changed
and allows you to comment on a change.
## Creating an issue for a vulnerability
## Create a GitLab issue for a vulnerability
You can create an issue for a vulnerability by selecting the **Create issue** button.
To create a GitLab issue for a vulnerability:
This allows the user to create a [confidential issue](../../project/issues/confidential_issues.md)
in the project the vulnerability came from. Fields are pre-populated with pertinent information
from the vulnerability report. After the issue is created, GitLab redirects you to the
issue page so you can edit, assign, or comment on the issue.
1. In GitLab, go to the vulnerability's page.
1. Select **Create issue**.
## Link issues to the vulnerability
An issue is created in the project, prepopulated with information from the vulnerability report.
The issue is then opened so you can take further action.
You can link one or more existing issues to the vulnerability. This allows you to
## Create a Jira issue for a vulnerability
> - [Introduced](https://gitlab.com/groups/gitlab-org/-/epics/4677) in GitLab 13.9.
> - It's [deployed behind a feature flag](../../../user/feature_flags.md), enabled by default.
> - It's enabled on GitLab.com.
> - It's recommended for production use.
> - For GitLab self-managed instances, GitLab administrators can opt to
> [disable it](#enable-or-disable-jira-integration-for-vulnerabilities).
WARNING:
This feature might not be available to you. Check the **version history** note above for details.
Prerequisites:
- [Enable Jira integration for vulnerabilities](../../project/integrations/jira.md). Select
**Enable Jira issues creation from vulnerabilities** when configuring the integration.
To create a Jira issue for a vulnerability:
1. Go to the vulnerability's page.
1. Select **Create Jira issue**.
An issue is created in the linked Jira project, with the **Summary** and **Description** fields
pre-populated. The Jira issue is then opened in a new browser tab.
### Enable or disable Jira integration for vulnerabilities **(ULTIMATE SELF)**
The option to create a Jira issue for a vulnerability is under development but ready for production
use. It is deployed behind a feature flag that is **enabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)
can opt to disable it.
To enable it:
```ruby
Feature.enable(:jira_for_vulnerabilities)
```
To disable it:
```ruby
Feature.disable(:jira_for_vulnerabilities)
```
## Link GitLab issues to the vulnerability
NOTE:
If Jira issue support is enabled, GitLab issues are disabled so this feature is not available.
You can link one or more existing GitLab issues to the vulnerability. This allows you to
indicate that this vulnerability affects multiple issues. It also allows you to indicate
that the resolution of one issue would resolve multiple vulnerabilities.
## Automatic remediation for vulnerabilities
Linked issues are shown in the Vulnerability Report and the vulnerability's page.
## Automatically remediate the vulnerability
You can fix some vulnerabilities by applying the solution that GitLab automatically
generates for you. [Read more about the automatic remediation for vulnerabilities feature](../index.md#automatic-remediation-for-vulnerabilities).

View File

@ -37,18 +37,12 @@ The Activity filter behaves differently from the other Vulnerability Report filt
Clicking any vulnerability in the table takes you to its
[vulnerability details](../vulnerabilities) page to see more information on that vulnerability.
To create an issue associated with the vulnerability, click the **Create Issue** button.
![Create an issue for the vulnerability](img/vulnerability_details_create_issue_v13_7.png)
After you create the issue, the linked issue icon in the vulnerability list:
- Indicates that an issue has been created for that vulnerability.
- Shows a tooltip that contains a link to the issue.
The **Activity** column indicates the number of issues that have been created for the vulnerability.
Hover over an **Activity** entry and select a link go to that issue.
![Display attached issues](img/vulnerability_list_table_v13_9.png)
Contents of the unfiltered vulnerability report can be exported using our [export feature](#export-vulnerabilities)
Contents of the unfiltered vulnerability report can be exported using our [export feature](#export-vulnerabilities).
You can also dismiss vulnerabilities in the table:

View File

@ -32,7 +32,8 @@ completed in GitLab and:
- The Jira issue shows the status of the deployment (in the sidebar as "deployments").
- Create or modify a feature flag that mentions a Jira issue in its description:
- The Jira issue shows the details of the feature-flag (in the sidebar as "feature flags").
- View a list of Jira issues directly in GitLab **(PREMIUM)**
- View a list of Jira issues directly in GitLab. **(PREMIUM)**
- Create a Jira issue from a vulnerability. **(ULTIMATE)**
Additional features provided by the Jira Development Panel integration include:
@ -90,37 +91,52 @@ Atlassian cloud, an **email and API token** are required. For more information,
> to enable Basic Auth. The cookie being added to each request is `OBBasicAuth` with
> a value of `fromDialog`.
To enable the Jira integration in a project, navigate to the
[Integrations page](overview.md#accessing-integrations) and click
the **Jira** service.
To enable the Jira integration in a project:
Select **Enable integration**.
1. Go to the project's [Integrations page](overview.md#accessing-integrations) and select the
**Jira** service.
Select a **Trigger** action. This determines whether a mention of a Jira issue in GitLab commits, merge requests, or both, should link the Jira issue back to that source commit/MR and transition the Jira issue, if indicated.
1. Select **Enable integration**.
To include a comment on the Jira issue when the above reference is made in GitLab, check **Enable comments**.
1. Select **Trigger** actions.
This determines whether a mention of a Jira issue in GitLab commits, merge requests, or both,
should link the Jira issue back to that source commit/MR and transition the Jira issue, if
indicated.
Enter the further details on the page as described in the following table.
1. To include a comment on the Jira issue when the above reference is made in GitLab, select
**Enable comments**.
| Field | Description |
| ----- | ----------- |
| `Web URL` | The base URL to the Jira instance web interface which is being linked to this GitLab project. For example, `https://jira.example.com`. |
| `Jira API URL` | The base URL to the Jira instance API. Web URL value is used if not set. For example, `https://jira-api.example.com`. Leave this field blank (or use the same value of `Web URL`) if using **Jira on Atlassian cloud**. |
| `Username or Email` | Created in [configure Jira](#configure-jira) step. Use `username` for **Jira Server** or `email` for **Jira on Atlassian cloud**. |
| `Password/API token` |Created in [configure Jira](#configure-jira) step. Use `password` for **Jira Server** or `API token` for **Jira on Atlassian cloud**. |
| `Jira workflow transition IDs` | Required for closing Jira issues via commits or merge requests. These are the IDs of transitions in Jira that move issues to a particular state. (See [Obtaining a transition ID](#obtaining-a-transition-id).) If you insert multiple transition IDs separated by `,` or `;`, the issue is moved to each state, one after another, using the given order. In GitLab 13.6 and earlier, field was called `Transition ID`. |
1. Select the **Comment detail**: **Standard** or **All details**.
To enable users to view Jira issues inside the GitLab project, select **Enable Jira issues** and enter a Jira project key. **(PREMIUM)**
1. Enter the further details on the page as described in the following table.
You can only display issues from a single Jira project within a given GitLab project.
| Field | Description |
| ----- | ----------- |
| `Web URL` | The base URL to the Jira instance web interface which is being linked to this GitLab project. For example, `https://jira.example.com`. |
| `Jira API URL` | The base URL to the Jira instance API. Web URL value is used if not set. For example, `https://jira-api.example.com`. Leave this field blank (or use the same value of `Web URL`) if using **Jira on Atlassian cloud**. |
| `Username or Email` | Created in [configure Jira](#configure-jira) step. Use `username` for **Jira Server** or `email` for **Jira on Atlassian cloud**. |
| `Password/API token` | Created in [configure Jira](#configure-jira) step. Use `password` for **Jira Server** or `API token` for **Jira on Atlassian cloud**. |
| `Jira workflow transition IDs` | Required for closing Jira issues via commits or merge requests. These are the IDs of transitions in Jira that move issues to a particular state. (See [Obtaining a transition ID](#obtaining-a-transition-id).) If you insert multiple transition IDs separated by `,` or `;`, the issue is moved to each state, one after another, using the given order. In GitLab 13.6 and earlier, field was called `Transition ID`. |
WARNING:
If you enable Jira issues with the setting above, all users that have access to this GitLab project
are able to view all issues from the specified Jira project.
1. To enable users to view Jira issues inside the GitLab project, select **Enable Jira issues** and
enter a Jira project key. **(PREMIUM)**
When you have configured all settings, click **Test settings and save changes**.
You can only display issues from a single Jira project within a given GitLab project.
Your GitLab project can now interact with all Jira projects in your instance and the project now displays a Jira link that opens the Jira project.
WARNING:
If you enable Jira issues with the setting above, all users that have access to this GitLab project
are able to view all issues from the specified Jira project.
1. To enable creation of issues for vulnerabilities, select **Enable Jira issues creation from vulnerabilities**.
1. Select the **Jira issue type**. If the dropdown is empty, select refresh (**{retry}**) and try again.
1. To verify the Jira connection is working, select **Test settings**.
1. Select **Save changes**.
Your GitLab project can now interact with all Jira projects in your instance and the project now
displays a Jira link that opens the Jira project.
#### Obtaining a transition ID

View File

@ -53,3 +53,4 @@ time.
| Record Jira time tracking information against an issue | No | Yes. Time can be specified via Jira Smart Commits. |
| Transition or close a Jira issue with a Git commit or merge request | Yes. Only a single transition type, typically configured to close the issue by setting it to Done. | Yes. Transition to any state using Jira Smart Commits. |
| Display a list of Jira issues | Yes **(PREMIUM)** | No |
| Create a Jira issue from a vulnerability or finding **(ULTIMATE)** | Yes | No |

View File

@ -194,6 +194,33 @@ is set for deletion, the merge request widget displays the
![Delete source branch status](img/remove_source_branch_status.png)
### Branch retargeting on merge **(FREE SELF)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/320902) in GitLab 13.9.
> - It's [deployed behind a feature flag](../../feature_flags.md), disabled by default.
> - It's disabled on GitLab.com.
> - It's not recommended for production use.
> - To use it in GitLab self-managed instances, ask a GitLab administrator to [enable it](#enable-or-disable-branch-retargeting-on-merge).
In specific circumstances, GitLab can retarget the destination branch of
open merge request, if the destination branch merges while the merge request is
open. Merge requests are often chained in this manner, with one merge request
depending on another:
- **Merge request 1**: merge `feature-alpha` into `master`.
- **Merge request 2**: merge `feature-beta` into `feature-alpha`.
These merge requests are usually handled in one of these ways:
- Merge request 1 is merged into `master` first. Merge request 2 is then
retargeted to `master`.
- Merge request 2 is merged into `feature-alpha`. The updated merge request 1, which
now contains the contents of `feature-alpha` and `feature-beta`, is merged into `master`.
GitLab retargets up to four merge requests when their target branch is merged into
`master`, so you don't need to perform this operation manually. Merge requests from
forks are not retargeted.
## Recommendations and best practices for Merge Requests
- When working locally in your branch, add multiple commits and only push when
@ -230,3 +257,22 @@ Feature.disable(:reviewer_approval_rules)
# For a single project
Feature.disable(:reviewer_approval_rules, Project.find(<project id>))
```
### Enable or disable branch retargeting on merge **(FREE SELF)**
Automatically retargeting merge requests is under development but ready for production use.
It is deployed behind a feature flag that is **enabled by default**.
[GitLab administrators with access to the GitLab Rails console](../../../administration/feature_flags.md)
can opt to disable it.
To enable it:
```ruby
Feature.enable(:retarget_merge_requests)
```
To disable it:
```ruby
Feature.disable(:retarget_merge_requests)
```

View File

@ -3295,6 +3295,9 @@ msgstr ""
msgid "An error occurred while fetching markdown preview"
msgstr ""
msgid "An error occurred while fetching participants."
msgstr ""
msgid "An error occurred while fetching pending comments"
msgstr ""
@ -3463,7 +3466,7 @@ msgstr ""
msgid "An error occurred while retrieving projects."
msgstr ""
msgid "An error occurred while saving assignees"
msgid "An error occurred while searching users."
msgstr ""
msgid "An error occurred while subscribing to notifications."
@ -26207,6 +26210,9 @@ msgstr ""
msgid "SecurityReports|Download results"
msgstr ""
msgid "SecurityReports|Download scanned resources"
msgstr ""
msgid "SecurityReports|Either you don't have permission to view this dashboard or the dashboard has not been setup. Please check your permission settings with your administrator or check your dashboard configurations to proceed."
msgstr ""

View File

@ -107,17 +107,20 @@ RSpec.describe 'Issue Boards', :js do
click_card(card)
page.within('.assignee') do
click_link 'Edit'
click_button('Edit')
wait_for_requests
page.within('.dropdown-menu-user') do
click_link user.name
assignee = first('.gl-avatar-labeled').find('.gl-avatar-labeled-label').text
wait_for_requests
page.within('.dropdown-menu-user') do
first('.gl-avatar-labeled').click
end
expect(page).to have_content(user.name)
click_button('Edit')
wait_for_requests
expect(page).to have_content(assignee)
end
expect(card).to have_selector('.avatar')
@ -128,15 +131,15 @@ RSpec.describe 'Issue Boards', :js do
click_card(card_two)
page.within('.assignee') do
click_link 'Edit'
click_button('Edit')
wait_for_requests
page.within('.dropdown-menu-user') do
click_link 'Unassigned'
find('[data-testid="unassign"]').click
end
close_dropdown_menu_if_visible
click_button('Edit')
wait_for_requests
expect(page).to have_content('None')
@ -165,17 +168,20 @@ RSpec.describe 'Issue Boards', :js do
click_card(card)
page.within('.assignee') do
click_link 'Edit'
click_button('Edit')
wait_for_requests
page.within('.dropdown-menu-user') do
click_link user.name
assignee = first('.gl-avatar-labeled').find('.gl-avatar-labeled-label').text
wait_for_requests
page.within('.dropdown-menu-user') do
first('.gl-avatar-labeled').click
end
expect(page).to have_content(user.name)
click_button('Edit')
wait_for_requests
expect(page).to have_content(assignee)
end
page.within(find('.board:nth-child(2)')) do
@ -183,9 +189,9 @@ RSpec.describe 'Issue Boards', :js do
end
page.within('.assignee') do
click_link 'Edit'
click_button('Edit')
expect(find('.dropdown-menu')).to have_selector('.is-active')
expect(find('.dropdown-menu')).to have_selector('.gl-new-dropdown-item-check-icon')
end
end
end

View File

@ -199,6 +199,38 @@ RSpec.describe 'User edit profile' do
expect(busy_status.checked?).to eq(true)
end
context 'with user status set to busy' do
let(:project) { create(:project, :public) }
let(:issue) { create(:issue, project: project, author: user) }
before do
toggle_busy_status
submit_settings
project.add_developer(user)
visit project_issue_path(project, issue)
end
it 'shows author as busy in the assignee dropdown' do
find('.block.assignee .edit-link').click
wait_for_requests
page.within '.dropdown-menu-user' do
expect(page).to have_content("#{user.name} (Busy)")
end
end
it 'displays the assignee busy status' do
click_button 'assign yourself'
wait_for_requests
visit project_issue_path(project, issue)
wait_for_requests
expect(page.find('[data-testid="expanded-assignee"]')).to have_text("#{user.name} (Busy)")
end
end
context 'with set_user_availability_status feature flag disabled' do
before do
stub_feature_flags(set_user_availability_status: false)

View File

@ -1,377 +0,0 @@
import {
GlDropdownItem,
GlAvatarLink,
GlAvatarLabeled,
GlSearchBoxByType,
GlLoadingIcon,
} from '@gitlab/ui';
import { mount, createLocalVue } from '@vue/test-utils';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import BoardAssigneeDropdown from '~/boards/components/board_assignee_dropdown.vue';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import searchUsers from '~/boards/graphql/users_search.query.graphql';
import store from '~/boards/stores';
import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue';
import MultiSelectDropdown from '~/vue_shared/components/sidebar/multiselect_dropdown.vue';
import getIssueParticipants from '~/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql';
import { participants } from '../mock_data';
const localVue = createLocalVue();
localVue.use(VueApollo);
describe('BoardCardAssigneeDropdown', () => {
let wrapper;
let fakeApollo;
let getIssueParticipantsSpy;
let getSearchUsersSpy;
let dispatchSpy;
const iid = '111';
const activeIssueName = 'test';
const anotherIssueName = 'hello';
const createComponent = (search = '', loading = false) => {
wrapper = mount(BoardAssigneeDropdown, {
data() {
return {
search,
selected: [],
issueParticipants: participants,
};
},
store,
provide: {
canUpdate: true,
rootPath: '',
},
mocks: {
$apollo: {
queries: {
searchUsers: {
loading,
},
},
},
},
});
};
const createComponentWithApollo = (search = '') => {
fakeApollo = createMockApollo([
[getIssueParticipants, getIssueParticipantsSpy],
[searchUsers, getSearchUsersSpy],
]);
wrapper = mount(BoardAssigneeDropdown, {
localVue,
apolloProvider: fakeApollo,
data() {
return {
search,
selected: [],
};
},
store,
provide: {
canUpdate: true,
rootPath: '',
},
});
};
const unassign = async () => {
wrapper.find('[data-testid="unassign"]').trigger('click');
await wrapper.vm.$nextTick();
};
const openDropdown = async () => {
wrapper.find('[data-testid="edit-button"]').trigger('click');
await wrapper.vm.$nextTick();
};
const findByText = (text) => {
return wrapper.findAll(GlDropdownItem).wrappers.find((node) => node.text().indexOf(text) === 0);
};
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
beforeEach(() => {
store.state.activeId = '1';
store.state.issues = {
1: {
iid,
assignees: [{ username: activeIssueName, name: activeIssueName, id: activeIssueName }],
},
};
dispatchSpy = jest.spyOn(store, 'dispatch').mockResolvedValue();
});
afterEach(() => {
window.gon = {};
jest.restoreAllMocks();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when mounted', () => {
beforeEach(() => {
createComponent();
});
it.each`
text
${anotherIssueName}
${activeIssueName}
`('finds item with $text', ({ text }) => {
const item = findByText(text);
expect(item.exists()).toBe(true);
});
it('renders gl-avatar-link in gl-dropdown-item', () => {
const item = findByText('hello');
expect(item.find(GlAvatarLink).exists()).toBe(true);
});
it('renders gl-avatar-labeled in gl-avatar-link', () => {
const item = findByText('hello');
expect(item.find(GlAvatarLink).find(GlAvatarLabeled).exists()).toBe(true);
});
});
describe('when selected users are present', () => {
it('renders a divider', () => {
createComponent();
expect(wrapper.find('[data-testid="selected-user-divider"]').exists()).toBe(true);
});
});
describe('when collapsed', () => {
it('renders IssuableAssignees', () => {
createComponent();
expect(wrapper.find(IssuableAssignees).isVisible()).toBe(true);
expect(wrapper.find(MultiSelectDropdown).isVisible()).toBe(false);
});
});
describe('when dropdown is open', () => {
beforeEach(async () => {
createComponent();
await openDropdown();
});
it('shows assignees dropdown', async () => {
expect(wrapper.find(IssuableAssignees).isVisible()).toBe(false);
expect(wrapper.find(MultiSelectDropdown).isVisible()).toBe(true);
});
it('shows the issue returned as the activeIssue', async () => {
expect(findByText(activeIssueName).props('isChecked')).toBe(true);
});
describe('when "Unassign" is clicked', () => {
it('unassigns assignees', async () => {
await unassign();
expect(findByText('Unassign').props('isChecked')).toBe(true);
});
});
describe('when an unselected item is clicked', () => {
beforeEach(async () => {
await unassign();
});
it('assigns assignee in the dropdown', async () => {
wrapper.find('[data-testid="item_test"]').trigger('click');
await wrapper.vm.$nextTick();
expect(findByText(activeIssueName).props('isChecked')).toBe(true);
});
it('calls setAssignees with username list', async () => {
wrapper.find('[data-testid="item_test"]').trigger('click');
await wrapper.vm.$nextTick();
document.body.click();
await wrapper.vm.$nextTick();
expect(store.dispatch).toHaveBeenCalledWith('setAssignees', [activeIssueName]);
});
});
describe('when the user off clicks', () => {
beforeEach(async () => {
await unassign();
document.body.click();
await wrapper.vm.$nextTick();
});
it('calls setAssignees with username list', async () => {
expect(store.dispatch).toHaveBeenCalledWith('setAssignees', []);
});
it('closes the dropdown', async () => {
expect(wrapper.find(IssuableAssignees).isVisible()).toBe(true);
});
});
});
it('renders divider after unassign', () => {
createComponent();
expect(wrapper.find('[data-testid="unassign-divider"]').exists()).toBe(true);
});
it.each`
assignees | expected
${[{ id: 5, username: '', name: '' }]} | ${'Assignee'}
${[{ id: 6, username: '', name: '' }, { id: 7, username: '', name: '' }]} | ${'2 Assignees'}
`(
'when assignees have a length of $assignees.length, it renders $expected',
({ assignees, expected }) => {
store.state.issues['1'].assignees = assignees;
createComponent();
expect(wrapper.find(BoardEditableItem).props('title')).toBe(expected);
},
);
describe('when searching users is loading', () => {
it('finds a loading icon in the dropdown', () => {
createComponent('test', true);
expect(findLoadingIcon().exists()).toBe(true);
});
});
describe('when participants loading is false', () => {
beforeEach(() => {
createComponent();
});
it('does not find GlLoading icon in the dropdown', () => {
expect(findLoadingIcon().exists()).toBe(false);
});
it('finds at least 1 GlDropdownItem', () => {
expect(wrapper.findAll(GlDropdownItem).length).toBeGreaterThan(0);
});
});
describe('Apollo', () => {
beforeEach(() => {
getIssueParticipantsSpy = jest.fn().mockResolvedValue({
data: {
issue: {
participants: {
nodes: [
{
username: 'participant',
name: 'participant',
webUrl: '',
avatarUrl: '',
id: '',
},
],
},
},
},
});
getSearchUsersSpy = jest.fn().mockResolvedValue({
data: {
users: {
nodes: [{ username: 'root', name: 'root', webUrl: '', avatarUrl: '', id: '' }],
},
},
});
});
describe('when search is empty', () => {
beforeEach(() => {
createComponentWithApollo();
});
it('calls getIssueParticipants', async () => {
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
expect(getIssueParticipantsSpy).toHaveBeenCalledWith({ id: 'gid://gitlab/Issue/111' });
});
});
describe('when search is not empty', () => {
beforeEach(() => {
createComponentWithApollo('search term');
});
it('calls searchUsers', async () => {
jest.runOnlyPendingTimers();
await wrapper.vm.$nextTick();
expect(getSearchUsersSpy).toHaveBeenCalledWith({ search: 'search term' });
});
});
});
it('finds GlSearchBoxByType', async () => {
createComponent();
await openDropdown();
expect(wrapper.find(GlSearchBoxByType).exists()).toBe(true);
});
describe('when assign-self is emitted from IssuableAssignees', () => {
const currentUser = { username: 'self', name: '', id: '' };
beforeEach(() => {
window.gon = { current_username: currentUser.username };
dispatchSpy.mockResolvedValue([currentUser]);
createComponent();
wrapper.find(IssuableAssignees).vm.$emit('assign-self');
});
it('calls setAssignees with currentUser', () => {
expect(store.dispatch).toHaveBeenCalledWith('setAssignees', currentUser.username);
});
it('adds the user to the selected list', async () => {
expect(findByText(currentUser.username).exists()).toBe(true);
});
});
describe('when setting an assignee', () => {
beforeEach(() => {
createComponent();
});
it('passes loading state from Vuex to BoardEditableItem', async () => {
store.state.isSettingAssignees = true;
await wrapper.vm.$nextTick();
expect(wrapper.find(BoardEditableItem).props('loading')).toBe(true);
});
});
});

View File

@ -11,8 +11,6 @@ import issueCreateMutation from '~/boards/graphql/issue_create.mutation.graphql'
import issueMoveListMutation from '~/boards/graphql/issue_move_list.mutation.graphql';
import actions, { gqlClient } from '~/boards/stores/actions';
import * as types from '~/boards/stores/mutation_types';
import createFlash from '~/flash';
import updateAssignees from '~/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql';
import {
mockLists,
mockListsById,
@ -726,65 +724,27 @@ describe('moveIssue', () => {
describe('setAssignees', () => {
const node = { username: 'name' };
const name = 'username';
const projectPath = 'h/h';
const refPath = `${projectPath}#3`;
const iid = '1';
describe('when succeeds', () => {
beforeEach(() => {
jest.spyOn(gqlClient, 'mutate').mockResolvedValue({
data: { issueSetAssignees: { issue: { assignees: { nodes: [{ ...node }] } } } },
});
});
it('calls mutate with the correct values', async () => {
await actions.setAssignees(
{ commit: () => {}, getters: { activeIssue: { iid, referencePath: refPath } } },
[name],
);
expect(gqlClient.mutate).toHaveBeenCalledWith({
mutation: updateAssignees,
variables: { iid, assigneeUsernames: [name], projectPath },
});
});
it('calls the correct mutation with the correct values', (done) => {
testAction(
actions.setAssignees,
{},
[node],
{ activeIssue: { iid, referencePath: refPath }, commit: () => {} },
[
{ type: types.SET_ASSIGNEE_LOADING, payload: true },
{
type: 'UPDATE_ISSUE_BY_ID',
payload: { prop: 'assignees', issueId: undefined, value: [node] },
},
{ type: types.SET_ASSIGNEE_LOADING, payload: false },
],
[],
done,
);
});
});
describe('when fails', () => {
beforeEach(() => {
jest.spyOn(gqlClient, 'mutate').mockRejectedValue();
});
it('calls createFlash', async () => {
await actions.setAssignees({
commit: () => {},
getters: { activeIssue: { iid, referencePath: refPath } },
});
expect(createFlash).toHaveBeenCalledWith({
message: 'An error occurred while updating assignees.',
});
});
});
});
describe('createNewIssue', () => {

View File

@ -0,0 +1,120 @@
import { GlLoadingIcon } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
describe('boards sidebar remove issue', () => {
let wrapper;
const findLoader = () => wrapper.find(GlLoadingIcon);
const findEditButton = () => wrapper.find('[data-testid="edit-button"]');
const findTitle = () => wrapper.find('[data-testid="title"]');
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
const findExpanded = () => wrapper.find('[data-testid="expanded-content"]');
const createComponent = ({ props = {}, slots = {}, canUpdate = false } = {}) => {
wrapper = shallowMount(SidebarEditableItem, {
attachTo: document.body,
provide: { canUpdate },
propsData: props,
slots,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('template', () => {
it('renders title', () => {
const title = 'Sidebar item title';
createComponent({ props: { title } });
expect(findTitle().text()).toBe(title);
});
it('hides edit button, loader and expanded content by default', () => {
createComponent();
expect(findEditButton().exists()).toBe(false);
expect(findLoader().exists()).toBe(false);
expect(findExpanded().isVisible()).toBe(false);
});
it('shows "None" if empty collapsed slot', () => {
createComponent();
expect(findCollapsed().text()).toBe('None');
});
it('renders collapsed content by default', () => {
const slots = { collapsed: '<div>Collapsed content</div>' };
createComponent({ slots });
expect(findCollapsed().text()).toBe('Collapsed content');
});
it('shows edit button if can update', () => {
createComponent({ canUpdate: true });
expect(findEditButton().exists()).toBe(true);
});
it('shows loading icon if loading', () => {
createComponent({ props: { loading: true } });
expect(findLoader().exists()).toBe(true);
});
it('shows expanded content and hides collapsed content when clicking edit button', async () => {
const slots = { default: '<div>Select item</div>' };
createComponent({ canUpdate: true, slots });
findEditButton().vm.$emit('click');
await wrapper.vm.$nextTick;
expect(findCollapsed().isVisible()).toBe(false);
expect(findExpanded().isVisible()).toBe(true);
});
});
describe('collapsing an item by offclicking', () => {
beforeEach(async () => {
createComponent({ canUpdate: true });
findEditButton().vm.$emit('click');
await wrapper.vm.$nextTick();
});
it('hides expanded section and displays collapsed section', async () => {
expect(findExpanded().isVisible()).toBe(true);
document.body.click();
await wrapper.vm.$nextTick();
expect(findCollapsed().isVisible()).toBe(true);
expect(findExpanded().isVisible()).toBe(false);
});
});
it('emits open when edit button is clicked and edit is initailized to false', async () => {
createComponent({ canUpdate: true });
findEditButton().vm.$emit('click');
await wrapper.vm.$nextTick();
expect(wrapper.emitted().open.length).toBe(1);
});
it('does not emits events when collapsing with false `emitEvent`', async () => {
createComponent({ canUpdate: true });
findEditButton().vm.$emit('click');
await wrapper.vm.$nextTick();
wrapper.vm.collapse({ emitEvent: false });
expect(wrapper.emitted().close).toBeUndefined();
});
});

View File

@ -3,9 +3,11 @@
require 'spec_helper'
RSpec.describe MergeRequests::PostMergeService do
let(:user) { create(:user) }
let(:merge_request) { create(:merge_request, assignees: [user]) }
let(:project) { merge_request.project }
include ProjectForksHelper
let_it_be(:user) { create(:user) }
let_it_be(:merge_request, reload: true) { create(:merge_request, assignees: [user]) }
let_it_be(:project) { merge_request.project }
subject { described_class.new(project, user).execute(merge_request) }
@ -128,5 +130,139 @@ RSpec.describe MergeRequests::PostMergeService do
expect(deploy_job.reload.canceled?).to be false
end
end
context 'for a merge request chain' do
before do
::MergeRequests::UpdateService
.new(project, user, force_remove_source_branch: '1')
.execute(merge_request)
end
context 'when there is another MR' do
let!(:another_merge_request) do
create(:merge_request,
source_project: source_project,
source_branch: 'my-awesome-feature',
target_project: merge_request.source_project,
target_branch: merge_request.source_branch
)
end
shared_examples 'does not retarget merge request' do
it 'another merge request is unchanged' do
expect { subject }.not_to change { another_merge_request.reload.target_branch }
.from(merge_request.source_branch)
end
end
shared_examples 'retargets merge request' do
it 'another merge request is retargeted' do
expect(SystemNoteService)
.to receive(:change_branch).once
.with(another_merge_request, another_merge_request.project, user,
'target', 'delete',
merge_request.source_branch, merge_request.target_branch)
expect { subject }.to change { another_merge_request.reload.target_branch }
.from(merge_request.source_branch)
.to(merge_request.target_branch)
end
context 'when FF retarget_merge_requests is disabled' do
before do
stub_feature_flags(retarget_merge_requests: false)
end
include_examples 'does not retarget merge request'
end
context 'when source branch is to be kept' do
before do
::MergeRequests::UpdateService
.new(project, user, force_remove_source_branch: false)
.execute(merge_request)
end
include_examples 'does not retarget merge request'
end
end
context 'in the same project' do
let(:source_project) { project }
it_behaves_like 'retargets merge request'
context 'and is closed' do
before do
another_merge_request.close
end
it_behaves_like 'does not retarget merge request'
end
context 'and is merged' do
before do
another_merge_request.mark_as_merged
end
it_behaves_like 'does not retarget merge request'
end
end
context 'in forked project' do
let!(:source_project) { fork_project(project) }
context 'when user has access to source project' do
before do
source_project.add_developer(user)
end
it_behaves_like 'retargets merge request'
end
context 'when user does not have access to source project' do
it_behaves_like 'does not retarget merge request'
end
end
context 'and current and another MR is from a fork' do
let(:project) { create(:project) }
let(:source_project) { fork_project(project) }
let(:merge_request) do
create(:merge_request,
source_project: source_project,
target_project: project
)
end
before do
source_project.add_developer(user)
end
it_behaves_like 'does not retarget merge request'
end
end
context 'when many merge requests are to be retargeted' do
let!(:many_merge_requests) do
create_list(:merge_request, 10, :unique_branches,
source_project: merge_request.source_project,
target_project: merge_request.source_project,
target_branch: merge_request.source_branch
)
end
it 'retargets only 4 of them' do
subject
expect(many_merge_requests.each(&:reload).pluck(:target_branch).tally)
.to eq(
merge_request.source_branch => 6,
merge_request.target_branch => 4
)
end
end
end
end
end

View File

@ -633,31 +633,37 @@ RSpec.describe MergeRequests::RefreshService do
end
context 'merge request metrics' do
let(:issue) { create :issue, project: @project }
let(:commit_author) { create :user }
let(:user) { create(:user) }
let(:project) { create(:project, :repository) }
let(:issue) { create(:issue, project: project) }
let(:commit) { project.commit }
before do
project.add_developer(commit_author)
project.add_developer(user)
allow(commit).to receive_messages(
safe_message: "Closes #{issue.to_reference}",
references: [issue],
author_name: commit_author.name,
author_email: commit_author.email,
author_name: user.name,
author_email: user.email,
committed_date: Time.current
)
allow_any_instance_of(MergeRequest).to receive(:commits).and_return(CommitCollection.new(@project, [commit], 'feature'))
end
context 'when the merge request is sourced from the same project' do
it 'creates a `MergeRequestsClosingIssues` record for each issue closed by a commit' do
merge_request = create(:merge_request, target_branch: 'master', source_branch: 'feature', source_project: @project)
refresh_service = service.new(@project, @user)
allow_any_instance_of(MergeRequest).to receive(:commits).and_return(
CommitCollection.new(project, [commit], 'close-by-commit')
)
merge_request = create(:merge_request,
target_branch: 'master',
source_branch: 'close-by-commit',
source_project: project)
refresh_service = service.new(project, user)
allow(refresh_service).to receive(:execute_hooks)
refresh_service.execute(@oldrev, @newrev, 'refs/heads/feature')
refresh_service.execute(@oldrev, @newrev, 'refs/heads/close-by-commit')
issue_ids = MergeRequestsClosingIssues.where(merge_request: merge_request).pluck(:issue_id)
expect(issue_ids).to eq([issue.id])
@ -666,16 +672,21 @@ RSpec.describe MergeRequests::RefreshService do
context 'when the merge request is sourced from a different project' do
it 'creates a `MergeRequestsClosingIssues` record for each issue closed by a commit' do
forked_project = fork_project(@project, @user, repository: true)
forked_project = fork_project(project, user, repository: true)
allow_any_instance_of(MergeRequest).to receive(:commits).and_return(
CommitCollection.new(forked_project, [commit], 'close-by-commit')
)
merge_request = create(:merge_request,
target_branch: 'master',
source_branch: 'feature',
target_project: @project,
target_project: project,
source_branch: 'close-by-commit',
source_project: forked_project)
refresh_service = service.new(@project, @user)
refresh_service = service.new(forked_project, user)
allow(refresh_service).to receive(:execute_hooks)
refresh_service.execute(@oldrev, @newrev, 'refs/heads/feature')
refresh_service.execute(@oldrev, @newrev, 'refs/heads/close-by-commit')
issue_ids = MergeRequestsClosingIssues.where(merge_request: merge_request).pluck(:issue_id)
expect(issue_ids).to eq([issue.id])

View File

@ -913,6 +913,33 @@ RSpec.describe MergeRequests::UpdateService, :mailer do
end
end
context 'updating `target_branch`' do
let(:merge_request) do
create(:merge_request,
source_project: project,
source_branch: 'mr-b',
target_branch: 'mr-a')
end
it 'updates to master' do
expect(SystemNoteService).to receive(:change_branch).with(
merge_request, project, user, 'target', 'update', 'mr-a', 'master'
)
expect { update_merge_request(target_branch: 'master') }
.to change { merge_request.reload.target_branch }.from('mr-a').to('master')
end
it 'updates to master because of branch deletion' do
expect(SystemNoteService).to receive(:change_branch).with(
merge_request, project, user, 'target', 'delete', 'mr-a', 'master'
)
expect { update_merge_request(target_branch: 'master', target_branch_was_deleted: true) }
.to change { merge_request.reload.target_branch }.from('mr-a').to('master')
end
end
it_behaves_like 'issuable record that supports quick actions' do
let(:existing_merge_request) { create(:merge_request, source_project: project) }
let(:issuable) { described_class.new(project, user, params).execute(existing_merge_request) }

View File

@ -213,15 +213,16 @@ RSpec.describe SystemNoteService do
describe '.change_branch' do
it 'calls MergeRequestsService' do
old_branch = double
new_branch = double
branch_type = double
old_branch = double('old_branch')
new_branch = double('new_branch')
branch_type = double('branch_type')
event_type = double('event_type')
expect_next_instance_of(::SystemNotes::MergeRequestsService) do |service|
expect(service).to receive(:change_branch).with(branch_type, old_branch, new_branch)
expect(service).to receive(:change_branch).with(branch_type, event_type, old_branch, new_branch)
end
described_class.change_branch(noteable, project, author, branch_type, old_branch, new_branch)
described_class.change_branch(noteable, project, author, branch_type, event_type, old_branch, new_branch)
end
end

View File

@ -167,18 +167,38 @@ RSpec.describe ::SystemNotes::MergeRequestsService do
end
describe '.change_branch' do
subject { service.change_branch('target', old_branch, new_branch) }
let(:old_branch) { 'old_branch'}
let(:new_branch) { 'new_branch'}
it_behaves_like 'a system note' do
let(:action) { 'branch' }
subject { service.change_branch('target', 'update', old_branch, new_branch) }
end
context 'when target branch name changed' do
it 'sets the note text' do
expect(subject.note).to eq "changed target branch from `#{old_branch}` to `#{new_branch}`"
context 'on update' do
subject { service.change_branch('target', 'update', old_branch, new_branch) }
it 'sets the note text' do
expect(subject.note).to eq "changed target branch from `#{old_branch}` to `#{new_branch}`"
end
end
context 'on delete' do
subject { service.change_branch('target', 'delete', old_branch, new_branch) }
it 'sets the note text' do
expect(subject.note).to eq "changed automatically target branch to `#{new_branch}` because `#{old_branch}` was deleted"
end
end
context 'for invalid event_type' do
subject { service.change_branch('target', 'invalid', old_branch, new_branch) }
it 'raises exception' do
expect { subject }.to raise_error /invalid value for event_type/
end
end
end
end