diff options
58 files changed, 1358 insertions, 923 deletions
diff --git a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue b/app/assets/javascripts/boards/components/board_assignee_dropdown.vue deleted file mode 100644 index 59d91f4ae72..00000000000 --- a/app/assets/javascripts/boards/components/board_assignee_dropdown.vue +++ /dev/null @@ -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> diff --git a/app/assets/javascripts/boards/components/board_sidebar.js b/app/assets/javascripts/boards/components/board_sidebar.js index f303c45a40e..6d5a13be3ac 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js +++ b/app/assets/javascripts/boards/components/board_sidebar.js @@ -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); diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index 15f7e2191a2..859295318ed 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -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, diff --git a/app/assets/javascripts/boards/models/issue.js b/app/assets/javascripts/boards/models/issue.js index bc23639df67..46d1239457d 100644 --- a/app/assets/javascripts/boards/models/issue.js +++ b/app/assets/javascripts/boards/models/issue.js @@ -53,6 +53,10 @@ class ListIssue { return boardsStore.findIssueAssignee(this, findAssignee); } + setAssignees(assignees) { + boardsStore.setIssueAssignees(this, assignees); + } + removeAssignee(removeAssignee) { boardsStore.removeIssueAssignee(this, removeAssignee); } diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index e2d07f9128c..a7cf1e9e647 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -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) => { diff --git a/app/assets/javascripts/boards/stores/boards_store.js b/app/assets/javascripts/boards/stores/boards_store.js index 39d95552084..fbff736c7e1 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js +++ b/app/assets/javascripts/boards/stores/boards_store.js @@ -724,6 +724,10 @@ const boardsStore = { } }, + setIssueAssignees(issue, assignees) { + issue.assignees = [...assignees]; + }, + removeIssueLabels(issue, labels) { labels.forEach(issue.removeLabel.bind(issue)); }, diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index 0f2afb779fd..c73c8fb6ca4 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -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" diff --git a/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql b/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql index fe01d2c2e78..42e646391a8 100644 --- a/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql +++ b/app/assets/javascripts/issuable_sidebar/queries/issue_sidebar.query.graphql @@ -3,6 +3,7 @@ query getProjectIssue($iid: String!, $fullPath: ID!) { project(fullPath: $fullPath) { issue(iid: $iid) { + id assignees { nodes { ...Author diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index 5dc1ab90e00..c3c009e680a 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -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> diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index 3c1b3afe889..e2dc37a0ac2 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -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> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue index 9b0d2228395..6595debf9a5 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.vue @@ -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" diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue new file mode 100644 index 00000000000..8f3f77cb5f0 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -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> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue index 00a2456c6b6..2c52d7142f7 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue @@ -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> diff --git a/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue new file mode 100644 index 00000000000..9da839cd133 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/sidebar_editable_item.vue @@ -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> diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js new file mode 100644 index 00000000000..274aa237aea --- /dev/null +++ b/app/assets/javascripts/sidebar/constants.js @@ -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, + }, +}; diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index bc019f09c85..662edbc4f8d 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -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, }, }), }); diff --git a/app/assets/javascripts/users_select/index.js b/app/assets/javascripts/users_select/index.js index 951f512b784..e1a4a74b982 100644 --- a/app/assets/javascripts/users_select/index.js +++ b/app/assets/javascripts/users_select/index.js @@ -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 diff --git a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue index c5bbe1b33fb..132abcab82b 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/sidebar/multiselect_dropdown.vue @@ -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> diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql deleted file mode 100644 index 612a0c02e82..00000000000 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/getIssueParticipants.query.graphql +++ /dev/null @@ -1,13 +0,0 @@ -query issueParticipants($id: IssueID!) { - issue(id: $id) { - participants { - nodes { - username - name - webUrl - avatarUrl - id - } - } - } -} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql new file mode 100644 index 00000000000..62c0b05426b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql @@ -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 + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql new file mode 100644 index 00000000000..a75ce85a1dc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_participants.query.graphql @@ -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 + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql index 9ead95a3801..2eb9bb4b07b 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/updateAssignees.mutation.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql @@ -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 } } } diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql new file mode 100644 index 00000000000..a0f15a07692 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql @@ -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 + } + } + } + } +} diff --git a/app/controllers/projects/services_controller.rb b/app/controllers/projects/services_controller.rb index 6ed9f74297d..b5c73f29784 100644 --- a/app/controllers/projects/services_controller.rb +++ b/app/controllers/projects/services_controller.rb @@ -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 diff --git a/app/helpers/boards_helper.rb b/app/helpers/boards_helper.rb index 80b6c62cb0b..73e7476b55d 100644 --- a/app/helpers/boards_helper.rb +++ b/app/helpers/boards_helper.rb @@ -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") diff --git a/app/services/merge_requests/post_merge_service.rb b/app/services/merge_requests/post_merge_service.rb index f04ec3c3e80..aafba9bfcef 100644 --- a/app/services/merge_requests/post_merge_service.rb +++ b/app/services/merge_requests/post_merge_service.rb @@ -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 diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 8cf84e32e85..1707daff734 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -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 diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 58f72e9badc..7d654ca7f5b 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -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 diff --git a/app/services/system_notes/merge_requests_service.rb b/app/services/system_notes/merge_requests_service.rb index a51e2053394..99e03e67bf1 100644 --- a/app/services/system_notes/merge_requests_service.rb +++ b/app/services/system_notes/merge_requests_service.rb @@ -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 diff --git a/app/views/shared/boards/components/sidebar/_assignee.html.haml b/app/views/shared/boards/components/sidebar/_assignee.html.haml index e22a7807b3b..c36f2c7c969 100644 --- a/app/views/shared/boards/components/sidebar/_assignee.html.haml +++ b/app/views/shared/boards/components/sidebar/_assignee.html.haml @@ -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" } diff --git a/app/views/shared/issuable/_sidebar_assignees.html.haml b/app/views/shared/issuable/_sidebar_assignees.html.haml index a7f435edb90..2b6920ed80f 100644 --- a/app/views/shared/issuable/_sidebar_assignees.html.haml +++ b/app/views/shared/issuable/_sidebar_assignees.html.haml @@ -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 diff --git a/changelogs/unreleased/263452-display-busy-status-in-issue-sidebar.yml b/changelogs/unreleased/263452-display-busy-status-in-issue-sidebar.yml new file mode 100644 index 00000000000..dc6ed606ac3 --- /dev/null +++ b/changelogs/unreleased/263452-display-busy-status-in-issue-sidebar.yml @@ -0,0 +1,5 @@ +--- +title: Display user busy status in issue sidebar +merge_request: 54165 +author: +type: added diff --git a/changelogs/unreleased/292035-convert-the-assignees-feature-into-a-widget.yml b/changelogs/unreleased/292035-convert-the-assignees-feature-into-a-widget.yml new file mode 100644 index 00000000000..bbc2ee0708c --- /dev/null +++ b/changelogs/unreleased/292035-convert-the-assignees-feature-into-a-widget.yml @@ -0,0 +1,5 @@ +--- +title: Create new assignees widget for boards +merge_request: 50054 +author: +type: changed diff --git a/changelogs/unreleased/fix-overflowing-design-buttons.yml b/changelogs/unreleased/fix-overflowing-design-buttons.yml new file mode 100644 index 00000000000..7e01779f438 --- /dev/null +++ b/changelogs/unreleased/fix-overflowing-design-buttons.yml @@ -0,0 +1,5 @@ +--- +title: Fix overflowing design buttons on mobile +merge_request: 54381 +author: +type: fixed diff --git a/changelogs/unreleased/retarget-branch.yml b/changelogs/unreleased/retarget-branch.yml new file mode 100644 index 00000000000..a879f7fdbec --- /dev/null +++ b/changelogs/unreleased/retarget-branch.yml @@ -0,0 +1,5 @@ +--- +title: Automatically retarget merge requests +merge_request: 53710 +author: +type: added diff --git a/config/feature_flags/development/jira_for_vulnerabilities.yml b/config/feature_flags/development/jira_for_vulnerabilities.yml index 32500c48da8..e00bd15cd09 100644 --- a/config/feature_flags/development/jira_for_vulnerabilities.yml +++ b/config/feature_flags/development/jira_for_vulnerabilities.yml @@ -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 diff --git a/config/feature_flags/development/retarget_merge_requests.yml b/config/feature_flags/development/retarget_merge_requests.yml new file mode 100644 index 00000000000..39ac4be0219 --- /dev/null +++ b/config/feature_flags/development/retarget_merge_requests.yml @@ -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 diff --git a/doc/.vale/gitlab/spelling-exceptions.txt b/doc/.vale/gitlab/spelling-exceptions.txt index de69eac29c6..375cecfdee4 100644 --- a/doc/.vale/gitlab/spelling-exceptions.txt +++ b/doc/.vale/gitlab/spelling-exceptions.txt @@ -488,6 +488,10 @@ resync resynced resyncing resyncs +retarget +retargeted +retargeting +retargets reusability reverified reverifies diff --git a/doc/administration/instance_limits.md b/doc/administration/instance_limits.md index ab0880df303..27e8b13812f 100644 --- a/doc/administration/instance_limits.md +++ b/doc/administration/instance_limits.md @@ -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). diff --git a/doc/user/application_security/img/create_issue_from_vulnerability_v13_3.png b/doc/user/application_security/img/create_issue_from_vulnerability_v13_3.png Binary files differdeleted file mode 100644 index b792fbc9af1..00000000000 --- a/doc/user/application_security/img/create_issue_from_vulnerability_v13_3.png +++ /dev/null diff --git a/doc/user/application_security/img/issue.png b/doc/user/application_security/img/issue.png Binary files differdeleted file mode 100644 index 6467201df3f..00000000000 --- a/doc/user/application_security/img/issue.png +++ /dev/null diff --git a/doc/user/application_security/index.md b/doc/user/application_security/index.md index e08af0d0bb9..4a23cd874be 100644 --- a/doc/user/application_security/index.md +++ b/doc/user/application_security/index.md @@ -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  -### 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. - - - -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. - - +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 diff --git a/doc/user/application_security/vulnerabilities/index.md b/doc/user/application_security/vulnerabilities/index.md index 705964dba66..50f05b687f7 100644 --- a/doc/user/application_security/vulnerabilities/index.md +++ b/doc/user/application_security/vulnerabilities/index.md @@ -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). diff --git a/doc/user/application_security/vulnerability_report/index.md b/doc/user/application_security/vulnerability_report/index.md index ad8f45b57c5..28083e09f1c 100644 --- a/doc/user/application_security/vulnerability_report/index.md +++ b/doc/user/application_security/vulnerability_report/index.md @@ -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. - - - -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.  -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: diff --git a/doc/user/project/integrations/jira.md b/doc/user/project/integrations/jira.md index aa5d11282d9..5857c3da803 100644 --- a/doc/user/project/integrations/jira.md +++ b/doc/user/project/integrations/jira.md @@ -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 diff --git a/doc/user/project/integrations/jira_integrations.md b/doc/user/project/integrations/jira_integrations.md index 3daea250aac..6a1529f001a 100644 --- a/doc/user/project/integrations/jira_integrations.md +++ b/doc/user/project/integrations/jira_integrations.md @@ -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 | diff --git a/doc/user/project/merge_requests/getting_started.md b/doc/user/project/merge_requests/getting_started.md index 92db8bb2618..b1a57d9c3e6 100644 --- a/doc/user/project/merge_requests/getting_started.md +++ b/doc/user/project/merge_requests/getting_started.md @@ -194,6 +194,33 @@ is set for deletion, the merge request widget displays the  +### 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) +``` diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 48fe53ebc50..e2a824b3299 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -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 "" diff --git a/spec/features/boards/sidebar_spec.rb b/spec/features/boards/sidebar_spec.rb index cf7554b3646..08bc70d7116 100644 --- a/spec/features/boards/sidebar_spec.rb +++ b/spec/features/boards/sidebar_spec.rb @@ -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 diff --git a/spec/features/profiles/user_edit_profile_spec.rb b/spec/features/profiles/user_edit_profile_spec.rb index 239bc04a9cb..bd4917824d1 100644 --- a/spec/features/profiles/user_edit_profile_spec.rb +++ b/spec/features/profiles/user_edit_profile_spec.rb @@ -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) diff --git a/spec/frontend/boards/components/board_assignee_dropdown_spec.js b/spec/frontend/boards/components/board_assignee_dropdown_spec.js deleted file mode 100644 index 629c2b3be83..00000000000 --- a/spec/frontend/boards/components/board_assignee_dropdown_spec.js +++ /dev/null @@ -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); - }); - }); -}); diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 1649bab3baf..32d0e7ae886 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -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', () => { diff --git a/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js new file mode 100644 index 00000000000..4ee12838491 --- /dev/null +++ b/spec/frontend/sidebar/components/assignees/sidebar_editable_item_spec.js @@ -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(); + }); +}); diff --git a/spec/services/merge_requests/post_merge_service_spec.rb b/spec/services/merge_requests/post_merge_service_spec.rb index 6523b5a158c..71329905558 100644 --- a/spec/services/merge_requests/post_merge_service_spec.rb +++ b/spec/services/merge_requests/post_merge_service_spec.rb @@ -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 diff --git a/spec/services/merge_requests/refresh_service_spec.rb b/spec/services/merge_requests/refresh_service_spec.rb index 3ccf02fcdfb..747ecbf4fa4 100644 --- a/spec/services/merge_requests/refresh_service_spec.rb +++ b/spec/services/merge_requests/refresh_service_spec.rb @@ -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]) diff --git a/spec/services/merge_requests/update_service_spec.rb b/spec/services/merge_requests/update_service_spec.rb index dff0d3297b3..edb95840604 100644 --- a/spec/services/merge_requests/update_service_spec.rb +++ b/spec/services/merge_requests/update_service_spec.rb @@ -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) } diff --git a/spec/services/system_note_service_spec.rb b/spec/services/system_note_service_spec.rb index 9c35f9e3817..df4880dfa13 100644 --- a/spec/services/system_note_service_spec.rb +++ b/spec/services/system_note_service_spec.rb @@ -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 diff --git a/spec/services/system_notes/merge_requests_service_spec.rb b/spec/services/system_notes/merge_requests_service_spec.rb index 50d16231e8f..2131f3d3bdf 100644 --- a/spec/services/system_notes/merge_requests_service_spec.rb +++ b/spec/services/system_notes/merge_requests_service_spec.rb @@ -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 |
