Add latest changes from gitlab-org/gitlab@master
This commit is contained in:
parent
3c97422b09
commit
839e879bcf
|
@ -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>
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -53,6 +53,10 @@ class ListIssue {
|
|||
return boardsStore.findIssueAssignee(this, findAssignee);
|
||||
}
|
||||
|
||||
setAssignees(assignees) {
|
||||
boardsStore.setIssueAssignees(this, assignees);
|
||||
}
|
||||
|
||||
removeAssignee(removeAssignee) {
|
||||
boardsStore.removeIssueAssignee(this, removeAssignee);
|
||||
}
|
||||
|
|
|
@ -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) => {
|
||||
|
|
|
@ -724,6 +724,10 @@ const boardsStore = {
|
|||
}
|
||||
},
|
||||
|
||||
setIssueAssignees(issue, assignees) {
|
||||
issue.assignees = [...assignees];
|
||||
},
|
||||
|
||||
removeIssueLabels(issue, labels) {
|
||||
labels.forEach(issue.removeLabel.bind(issue));
|
||||
},
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
query getProjectIssue($iid: String!, $fullPath: ID!) {
|
||||
project(fullPath: $fullPath) {
|
||||
issue(iid: $iid) {
|
||||
id
|
||||
assignees {
|
||||
nodes {
|
||||
...Author
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
|
@ -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,
|
||||
},
|
||||
};
|
|
@ -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,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -1,13 +0,0 @@
|
|||
query issueParticipants($id: IssueID!) {
|
||||
issue(id: $id) {
|
||||
participants {
|
||||
nodes {
|
||||
username
|
||||
name
|
||||
webUrl
|
||||
avatarUrl
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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" }
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Display user busy status in issue sidebar
|
||||
merge_request: 54165
|
||||
author:
|
||||
type: added
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Create new assignees widget for boards
|
||||
merge_request: 50054
|
||||
author:
|
||||
type: changed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Fix overflowing design buttons on mobile
|
||||
merge_request: 54381
|
||||
author:
|
||||
type: fixed
|
|
@ -0,0 +1,5 @@
|
|||
---
|
||||
title: Automatically retarget merge requests
|
||||
merge_request: 53710
|
||||
author:
|
||||
type: added
|
|
@ -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
|
||||
|
|
|
@ -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
|
|
@ -488,6 +488,10 @@ resync
|
|||
resynced
|
||||
resyncing
|
||||
resyncs
|
||||
retarget
|
||||
retargeted
|
||||
retargeting
|
||||
retargets
|
||||
reusability
|
||||
reverified
|
||||
reverifies
|
||||
|
|
|
@ -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 |
|
@ -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
|
||||
|
|
|
@ -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).
|
||||
|
|
|
@ -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:
|
||||
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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 |
|
||||
|
|
|
@ -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)
|
||||
```
|
||||
|
|
|
@ -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 ""
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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', () => {
|
||||
|
|
|
@ -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();
|
||||
});
|
||||
});
|
|
@ -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
|
||||
|
|
|
@ -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])
|
||||
|
|
|
@ -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) }
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue