diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-03-15 15:07:44 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-03-15 15:07:44 +0300 |
commit | 6a9ab27963fc1479fe7c78581b942c8dcce322e5 (patch) | |
tree | 8d32f4f66efde1b426658a74d0276e5250091ab7 /app | |
parent | 389d5aa505a916b0506b7b73dcc3be342d724976 (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app')
49 files changed, 670 insertions, 209 deletions
diff --git a/app/assets/javascripts/google_cloud/components/home.vue b/app/assets/javascripts/google_cloud/components/home.vue index 5f98eaf4a05..e41337e2679 100644 --- a/app/assets/javascripts/google_cloud/components/home.vue +++ b/app/assets/javascripts/google_cloud/components/home.vue @@ -1,6 +1,7 @@ <script> import { GlTabs, GlTab } from '@gitlab/ui'; import DeploymentsServiceTable from './deployments_service_table.vue'; +import RevokeOauth from './revoke_oauth.vue'; import ServiceAccountsList from './service_accounts_list.vue'; import GcpRegionsList from './gcp_regions_list.vue'; @@ -9,6 +10,7 @@ export default { GlTabs, GlTab, DeploymentsServiceTable, + RevokeOauth, ServiceAccountsList, GcpRegionsList, }, @@ -41,6 +43,10 @@ export default { type: Array, required: true, }, + revokeOauthUrl: { + type: String, + required: true, + }, }, }; </script> @@ -61,6 +67,8 @@ export default { :create-url="configureGcpRegionsUrl" :list="gcpRegions" /> + <hr v-if="revokeOauthUrl" /> + <revoke-oauth v-if="revokeOauthUrl" :url="revokeOauthUrl" /> </gl-tab> <gl-tab :title="__('Deployments')"> <deployments-service-table diff --git a/app/assets/javascripts/google_cloud/components/revoke_oauth.vue b/app/assets/javascripts/google_cloud/components/revoke_oauth.vue new file mode 100644 index 00000000000..07d966894f6 --- /dev/null +++ b/app/assets/javascripts/google_cloud/components/revoke_oauth.vue @@ -0,0 +1,38 @@ +<script> +import { GlButton, GlForm } from '@gitlab/ui'; +import csrf from '~/lib/utils/csrf'; +import { s__ } from '~/locale'; + +export const GOOGLE_CLOUD_REVOKE_TITLE = s__('GoogleCloud|Revoke authorizations'); +export const GOOGLE_CLOUD_REVOKE_DESCRIPTION = s__( + 'GoogleCloud|Revoke authorizations granted to GitLab. This does not invalidate service accounts.', +); + +export default { + components: { GlButton, GlForm }, + csrf, + props: { + url: { + type: String, + required: true, + }, + }, + i18n: { + title: GOOGLE_CLOUD_REVOKE_TITLE, + description: GOOGLE_CLOUD_REVOKE_DESCRIPTION, + }, +}; +</script> + +<template> + <div class="gl-mx-4"> + <h2 class="gl-font-size-h2">{{ $options.i18n.title }}</h2> + <p>{{ $options.i18n.description }}</p> + <gl-form :action="url" method="post"> + <input :value="$options.csrf.token" type="hidden" name="authenticity_token" /> + <gl-button category="secondary" variant="warning" type="submit"> + {{ $options.i18n.title }} + </gl-button> + </gl-form> + </div> +</template> diff --git a/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql b/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql new file mode 100644 index 00000000000..2bd016feb19 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/queries/users_search_with_mr_permissions.graphql @@ -0,0 +1,24 @@ +#import "../fragments/user.fragment.graphql" +#import "~/graphql_shared/fragments/user_availability.fragment.graphql" + +query projectUsersSearchWithMRPermissions( + $search: String! + $fullPath: ID! + $mergeRequestId: MergeRequestID! +) { + workspace: project(fullPath: $fullPath) { + id + users: projectMembers(search: $search, relations: [DIRECT, INHERITED, INVITED_GROUPS]) { + nodes { + id + mergeRequestInteraction(id: $mergeRequestId) { + canMerge + } + user { + ...User + ...UserAvailability + } + } + } + } +} diff --git a/app/assets/javascripts/header_search/store/getters.js b/app/assets/javascripts/header_search/store/getters.js index adaacc6ecf0..87dec95153f 100644 --- a/app/assets/javascripts/header_search/store/getters.js +++ b/app/assets/javascripts/header_search/store/getters.js @@ -21,6 +21,8 @@ export const searchQuery = (state) => { group_id: state.searchContext?.group?.id, scope: state.searchContext?.scope, snippets: state.searchContext?.for_snippets ? true : null, + search_code: state.searchContext?.code_search ? true : null, + repository_ref: state.searchContext?.ref, }, isNil, ); @@ -98,6 +100,8 @@ export const projectUrl = (state) => { group_id: state.searchContext?.group?.id, scope: state.searchContext?.scope, snippets: state.searchContext?.for_snippets ? true : null, + search_code: state.searchContext?.code_search ? true : null, + repository_ref: state.searchContext?.ref, }, isNil, ); @@ -113,6 +117,8 @@ export const groupUrl = (state) => { group_id: state.searchContext?.group?.id, scope: state.searchContext?.scope, snippets: state.searchContext?.for_snippets ? true : null, + search_code: state.searchContext?.code_search ? true : null, + repository_ref: state.searchContext?.ref, }, isNil, ); @@ -127,6 +133,8 @@ export const allUrl = (state) => { nav_source: 'navbar', scope: state.searchContext?.scope, snippets: state.searchContext?.for_snippets ? true : null, + search_code: state.searchContext?.code_search ? true : null, + repository_ref: state.searchContext?.ref, }, isNil, ); @@ -140,7 +148,7 @@ export const scopedSearchOptions = (state, getters) => { if (state.searchContext?.project) { options.push({ html_id: 'scoped-in-project', - scope: state.searchContext?.project.name, + scope: state.searchContext.project?.name || '', description: MSG_IN_PROJECT, url: getters.projectUrl, }); @@ -149,7 +157,7 @@ export const scopedSearchOptions = (state, getters) => { if (state.searchContext?.group) { options.push({ html_id: 'scoped-in-group', - scope: state.searchContext?.group.name, + scope: state.searchContext.group?.name || '', description: MSG_IN_GROUP, url: getters.groupUrl, }); diff --git a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue index 7bc096ce2c8..9cb070a5517 100644 --- a/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue +++ b/app/assets/javascripts/pipeline_editor/components/drawer/pipeline_editor_drawer.vue @@ -2,7 +2,6 @@ import { GlButton, GlIcon } from '@gitlab/ui'; import { __ } from '~/locale'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import { experiment } from '~/experimentation/utils'; import { DRAWER_EXPANDED_KEY } from '../../constants'; import FirstPipelineCard from './cards/first_pipeline_card.vue'; import GettingStartedCard from './cards/getting_started_card.vue'; @@ -50,29 +49,8 @@ export default { }, mounted() { this.setTopPosition(); - this.setInitialExpandState(); }, methods: { - setInitialExpandState() { - let isExpanded; - - experiment('pipeline_editor_walkthrough', { - control: () => { - isExpanded = true; - }, - candidate: () => { - isExpanded = false; - }, - }); - - // We check in the local storage and if no value is defined, we want the default - // to be true. We want to explicitly set it to true here so that the drawer - // animates to open on load. - const localValue = localStorage.getItem(this.$options.localDrawerKey); - if (localValue === null) { - this.isExpanded = isExpanded; - } - }, setTopPosition() { const navbarEl = document.querySelector('.js-navbar'); diff --git a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue index c75b1d4bb11..5cff93c884f 100644 --- a/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue +++ b/app/assets/javascripts/pipeline_editor/components/pipeline_editor_tabs.vue @@ -4,7 +4,6 @@ import { s__ } from '~/locale'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { getParameterValues, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; -import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; import { CREATE_TAB, EDITOR_APP_STATUS_EMPTY, @@ -66,7 +65,6 @@ export default { GlTabs, PipelineGraph, TextEditor, - GitlabExperiment, WalkthroughPopover, }, mixins: [glFeatureFlagsMixin()], @@ -158,11 +156,7 @@ export default { data-testid="editor-tab" @click="setCurrentTab($options.tabConstants.CREATE_TAB)" > - <gitlab-experiment name="pipeline_editor_walkthrough"> - <template #candidate> - <walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" /> - </template> - </gitlab-experiment> + <walkthrough-popover v-if="isNewCiConfigFile" v-on="$listeners" /> <ci-editor-header /> <text-editor :commit-sha="commitSha" :value="ciFileContent" v-on="$listeners" /> </editor-tab> diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue index da9ff407faf..240e12ee597 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar.vue @@ -1,5 +1,6 @@ <script> import { GlIcon } from '@gitlab/ui'; +import { IssuableType } from '~/issues/constants'; import { __, sprintf } from '~/locale'; export default { @@ -31,10 +32,11 @@ export default { ); }, isMergeRequest() { - return this.issuableType === 'merge_request'; + return this.issuableType === IssuableType.MergeRequest; }, hasMergeIcon() { - return this.isMergeRequest && !this.user.can_merge; + const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge; + return this.isMergeRequest && !canMerge; }, }, }; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue index 2a237e7ace0..578c344da02 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_avatar_link.vue @@ -1,5 +1,6 @@ <script> import { GlTooltipDirective, GlLink } from '@gitlab/ui'; +import { IssuableType } from '~/issues/constants'; import { __ } from '~/locale'; import { isUserBusy } from '~/set_status_modal/utils'; import AssigneeAvatar from './assignee_avatar.vue'; @@ -71,7 +72,8 @@ export default { }, computed: { cannotMerge() { - return this.issuableType === 'merge_request' && !this.user.can_merge; + const canMerge = this.user.mergeRequestInteraction?.canMerge || this.user.can_merge; + return this.issuableType === IssuableType.MergeRequest && !canMerge; }, tooltipTitle() { const { name = '', availability = '' } = this.user; diff --git a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue index 6a74ab83c22..856687c00ae 100644 --- a/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue +++ b/app/assets/javascripts/sidebar/components/assignees/collapsed_assignee_list.vue @@ -58,7 +58,7 @@ export default { return this.users.length > 2; }, allAssigneesCanMerge() { - return this.users.every((user) => user.can_merge); + return this.users.every((user) => user.can_merge || user.mergeRequestInteraction?.canMerge); }, sidebarAvatarCounter() { if (this.users.length > DEFAULT_MAX_COUNTER) { @@ -77,7 +77,9 @@ export default { return ''; } - const mergeLength = this.users.filter((u) => u.can_merge).length; + const mergeLength = this.users.filter( + (u) => u.can_merge || u.mergeRequestInteraction?.canMerge, + ).length; if (mergeLength === this.users.length) { return ''; diff --git a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue index a3379784bc1..59a4eb54bbe 100644 --- a/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/issuable_assignees.vue @@ -44,7 +44,7 @@ export default { <div class="gl-display-flex gl-flex-direction-column issuable-assignees"> <div v-if="emptyUsers" - class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-2 hide-collapsed" + class="gl-display-flex gl-align-items-center gl-text-gray-500 hide-collapsed" data-testid="none" > <span> {{ __('None') }}</span> @@ -65,7 +65,7 @@ export default { v-else :users="users" :issuable-type="issuableType" - class="gl-text-gray-800 gl-mt-2 hide-collapsed" + class="gl-text-gray-800 hide-collapsed" @toggle-attention-requested="toggleAttentionRequested" /> </div> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue index 9c031ae64f8..7743004a293 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees_widget.vue @@ -1,6 +1,5 @@ <script> import { GlDropdownItem } from '@gitlab/ui'; -import { cloneDeep } from 'lodash'; import Vue from 'vue'; import createFlash from '~/flash'; import { IssuableType } from '~/issues/constants'; @@ -101,7 +100,10 @@ export default { } const issuable = data.workspace?.issuable; if (issuable) { - this.selected = cloneDeep(issuable.assignees.nodes); + this.selected = issuable.assignees.nodes.map((node) => ({ + ...node, + canMerge: node.mergeRequestInteraction?.canMerge || false, + })); } }, error() { @@ -141,6 +143,7 @@ export default { username: gon?.current_username, name: gon?.current_user_fullname, avatarUrl: gon?.current_user_avatar_url, + canMerge: this.issuable?.userPermissions?.canMerge || false, }; }, signedIn() { @@ -206,8 +209,8 @@ export default { expandWidget() { this.$refs.toggle.expand(); }, - focusSearch() { - this.$refs.userSelect.focusSearch(); + showDropdown() { + this.$refs.userSelect.showDropdown(); }, showError() { createFlash({ message: __('An error occurred while fetching participants.') }); @@ -236,11 +239,11 @@ export default { :initial-loading="isAssigneesLoading" :title="assigneeText" :is-dirty="isDirty" - @open="focusSearch" + @open="showDropdown" @close="saveAssignees" > <template #collapsed> - <slot name="collapsed" :users="assignees" :on-click="expandWidget"></slot> + <slot name="collapsed" :users="assignees"></slot> <issuable-assignees :users="assignees" :issuable-type="issuableType" @@ -256,12 +259,13 @@ export default { :text="$options.i18n.assignees" :header-text="$options.i18n.assignTo" :iid="iid" + :issuable-id="issuableId" :full-path="fullPath" :allow-multiple-assignees="allowMultipleAssignees" :current-user="currentUser" :issuable-type="issuableType" :is-editing="edit" - class="gl-w-full dropdown-menu-user" + class="gl-w-full dropdown-menu-user gl-mt-n3" @toggle="collapseWidget" @error="showError" @input="setDirtyState" diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue index 8ef65ef7308..28bc5afc1a4 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_invite_members.vue @@ -30,6 +30,6 @@ export default { :event="$options.dataTrackEvent" :label="$options.dataTrackLabel" :trigger-source="triggerSource" - classes="gl-display-block gl-pl-6 gl-hover-text-decoration-none gl-hover-text-blue-800!" + classes="gl-display-block gl-pl-0 gl-hover-text-decoration-none gl-hover-text-blue-800!" /> </template> diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue index e2a38a100b9..19f588b28be 100644 --- a/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue +++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_participant.vue @@ -1,17 +1,24 @@ <script> -import { GlAvatarLabeled, GlAvatarLink } from '@gitlab/ui'; +import { GlAvatarLabeled, GlAvatarLink, GlIcon } from '@gitlab/ui'; +import { IssuableType } from '~/issues/constants'; import { s__, sprintf } from '~/locale'; export default { components: { GlAvatarLabeled, GlAvatarLink, + GlIcon, }, props: { user: { type: Object, required: true, }, + issuableType: { + type: String, + required: false, + default: IssuableType.Issue, + }, }, computed: { userLabel() { @@ -22,6 +29,9 @@ export default { author: this.user.name, }); }, + hasCannotMergeIcon() { + return this.issuableType === IssuableType.MergeRequest && !this.user.canMerge; + }, }, }; </script> @@ -31,9 +41,19 @@ export default { <gl-avatar-labeled :size="32" :label="userLabel" - :sub-label="user.username" + :sub-label="`@${user.username}`" :src="user.avatarUrl || user.avatar || user.avatar_url" - class="gl-align-items-center" - /> + class="gl-align-items-center gl-relative" + > + <template #meta> + <gl-icon + v-if="hasCannotMergeIcon" + name="warning-solid" + aria-hidden="true" + class="merge-icon" + :size="12" + /> + </template> + </gl-avatar-labeled> </gl-avatar-link> </template> diff --git a/app/assets/javascripts/sidebar/components/incidents/constants.js b/app/assets/javascripts/sidebar/components/incidents/constants.js new file mode 100644 index 00000000000..cd05a6099fd --- /dev/null +++ b/app/assets/javascripts/sidebar/components/incidents/constants.js @@ -0,0 +1,25 @@ +import { s__ } from '~/locale'; + +export const STATUS_TRIGGERED = 'TRIGGERED'; +export const STATUS_ACKNOWLEDGED = 'ACKNOWLEDGED'; +export const STATUS_RESOLVED = 'RESOLVED'; + +export const STATUS_TRIGGERED_LABEL = s__('IncidentManagement|Triggered'); +export const STATUS_ACKNOWLEDGED_LABEL = s__('IncidentManagement|Acknowledged'); +export const STATUS_RESOLVED_LABEL = s__('IncidentManagement|Resolved'); + +export const STATUS_LABELS = { + [STATUS_TRIGGERED]: STATUS_TRIGGERED_LABEL, + [STATUS_ACKNOWLEDGED]: STATUS_ACKNOWLEDGED_LABEL, + [STATUS_RESOLVED]: STATUS_RESOLVED_LABEL, +}; + +export const i18n = { + fetchError: s__( + 'IncidentManagement|An error occurred while fetching the incident status. Please reload the page.', + ), + title: s__('IncidentManagement|Status'), + updateError: s__( + 'IncidentManagement|An error occurred while updating the incident status. Please reload the page and try again.', + ), +}; diff --git a/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue new file mode 100644 index 00000000000..2c32cf89387 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/incidents/escalation_status.vue @@ -0,0 +1,61 @@ +<script> +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { i18n, STATUS_ACKNOWLEDGED, STATUS_TRIGGERED, STATUS_RESOLVED } from './constants'; +import { getStatusLabel } from './utils'; + +const STATUS_LIST = [STATUS_TRIGGERED, STATUS_ACKNOWLEDGED, STATUS_RESOLVED]; + +export default { + i18n, + STATUS_LIST, + components: { + GlDropdown, + GlDropdownItem, + }, + props: { + value: { + type: String, + required: false, + default: null, + validator(value) { + return [...STATUS_LIST, null].includes(value); + }, + }, + }, + computed: { + currentStatusLabel() { + return this.getStatusLabel(this.value); + }, + }, + methods: { + show() { + this.$refs.dropdown.show(); + }, + hide() { + this.$refs.dropdown.hide(); + }, + getStatusLabel, + }, +}; +</script> + +<template> + <gl-dropdown + ref="dropdown" + block + :text="currentStatusLabel" + toggle-class="dropdown-menu-toggle gl-mb-2" + > + <slot name="header"> </slot> + <gl-dropdown-item + v-for="status in $options.STATUS_LIST" + :key="status" + data-testid="status-dropdown-item" + :is-check-item="true" + :is-checked="status === value" + @click="$emit('input', status)" + > + {{ getStatusLabel(status) }} + </gl-dropdown-item> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue new file mode 100644 index 00000000000..67ae1e6fcab --- /dev/null +++ b/app/assets/javascripts/sidebar/components/incidents/sidebar_escalation_status.vue @@ -0,0 +1,135 @@ +<script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { escalationStatusQuery, escalationStatusMutation } from '~/sidebar/constants'; +import { createAlert } from '~/flash'; +import { logError } from '~/lib/logger'; +import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation_status.vue'; +import SidebarEditableItem from '../sidebar_editable_item.vue'; +import { i18n } from './constants'; +import { getStatusLabel } from './utils'; + +export default { + i18n, + components: { + EscalationStatus, + SidebarEditableItem, + GlIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + iid: { + type: String, + required: true, + }, + projectPath: { + type: String, + required: true, + }, + issuableType: { + required: true, + type: String, + }, + }, + data() { + return { + status: null, + isUpdating: false, + }; + }, + apollo: { + status: { + query() { + return escalationStatusQuery; + }, + variables() { + return { + fullPath: this.projectPath, + iid: this.iid, + }; + }, + update(data) { + return data.workspace?.issuable?.escalationStatus; + }, + error(error) { + const message = this.$options.i18n.fetchError; + createAlert({ message }); + logError(message, error); + }, + }, + }, + computed: { + isLoading() { + return this.$apollo.queries.status.loading; + }, + currentStatusLabel() { + return getStatusLabel(this.status); + }, + tooltipText() { + return `${this.$options.i18n.title}: ${this.currentStatusLabel}`; + }, + }, + methods: { + updateStatus(status) { + this.isUpdating = true; + this.closeSidebar(); + return this.$apollo + .mutate({ + mutation: escalationStatusMutation, + variables: { + status, + iid: this.iid, + projectPath: this.projectPath, + }, + }) + .then(({ data: { issueSetEscalationStatus } }) => { + this.status = issueSetEscalationStatus.issue.escalationStatus; + }) + .catch((error) => { + const message = this.$options.i18n.updateError; + createAlert({ message }); + logError(message, error); + }) + .finally(() => { + this.isUpdating = false; + }); + }, + closeSidebar() { + this.close(); + this.$refs.editable.collapse(); + }, + open() { + this.$refs.escalationStatus.show(); + }, + close() { + this.$refs.escalationStatus.hide(); + }, + }, +}; +</script> + +<template> + <sidebar-editable-item + ref="editable" + :title="$options.i18n.title" + :initial-loading="isLoading" + :loading="isUpdating" + @open="open" + @close="close" + > + <template #default> + <escalation-status ref="escalationStatus" :value="status" @input="updateStatus" /> + </template> + <template #collapsed> + <div + v-gl-tooltip.viewport.left="tooltipText" + class="sidebar-collapsed-icon" + data-testid="status-icon" + > + <gl-icon name="status" :size="16" /> + </div> + <span class="hide-collapsed text-secondary">{{ currentStatusLabel }}</span> + </template> + </sidebar-editable-item> +</template> diff --git a/app/assets/javascripts/sidebar/components/incidents/utils.js b/app/assets/javascripts/sidebar/components/incidents/utils.js new file mode 100644 index 00000000000..59bf1ea466c --- /dev/null +++ b/app/assets/javascripts/sidebar/components/incidents/utils.js @@ -0,0 +1,5 @@ +import { s__ } from '~/locale'; + +import { STATUS_LABELS } from './constants'; + +export const getStatusLabel = (status) => STATUS_LABELS[status] ?? s__('IncidentManagement|None'); diff --git a/app/assets/javascripts/sidebar/constants.js b/app/assets/javascripts/sidebar/constants.js index 0238fb8e8d5..989dc574bc3 100644 --- a/app/assets/javascripts/sidebar/constants.js +++ b/app/assets/javascripts/sidebar/constants.js @@ -1,7 +1,8 @@ import { s__, sprintf } from '~/locale'; import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; +import userSearchQuery from '~/graphql_shared/queries/users_search.query.graphql'; +import userSearchWithMRPermissionsQuery from '~/graphql_shared/queries/users_search_with_mr_permissions.graphql'; import { IssuableType, WorkspaceType } from '~/issues/constants'; -import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import epicConfidentialQuery from '~/sidebar/queries/epic_confidential.query.graphql'; import epicDueDateQuery from '~/sidebar/queries/epic_due_date.query.graphql'; import epicParticipantsQuery from '~/sidebar/queries/epic_participants.query.graphql'; @@ -49,12 +50,12 @@ import getMergeRequestParticipants from '~/vue_shared/components/sidebar/queries import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql'; import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; import updateMergeRequestAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_mr_assignees.mutation.graphql'; +import getEscalationStatusQuery from '~/sidebar/queries/escalation_status.query.graphql'; +import updateEscalationStatusMutation from '~/sidebar/queries/update_escalation_status.mutation.graphql'; import projectIssueMilestoneMutation from './queries/project_issue_milestone.mutation.graphql'; import projectIssueMilestoneQuery from './queries/project_issue_milestone.query.graphql'; import projectMilestonesQuery from './queries/project_milestones.query.graphql'; -export const ASSIGNEES_DEBOUNCE_DELAY = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; - export const defaultEpicSort = 'TITLE_ASC'; export const epicIidPattern = /^&(?<iid>\d+)$/; @@ -91,6 +92,15 @@ export const participantsQueries = { }, }; +export const userSearchQueries = { + [IssuableType.Issue]: { + query: userSearchQuery, + }, + [IssuableType.MergeRequest]: { + query: userSearchWithMRPermissionsQuery, + }, +}; + export const confidentialityQueries = { [IssuableType.Issue]: { query: issueConfidentialQuery, @@ -305,3 +315,6 @@ export function dropdowni18nText(issuableAttribute, issuableType) { ), }; } + +export const escalationStatusQuery = getEscalationStatusQuery; +export const escalationStatusMutation = updateEscalationStatusMutation; diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 0fb31ec92ca..2a7d967cb61 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -10,6 +10,7 @@ import { isInIssuePage, isInDesignPage, isInIncidentPage, + isInMRPage, parseBoolean, } from '~/lib/utils/common_utils'; import { __ } from '~/locale'; @@ -31,6 +32,7 @@ import { refreshUserMergeRequestCounts } from '~/commons/nav/user_merge_requests import Translate from '../vue_shared/translate'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue'; +import SidebarEscalationStatus from './components/incidents/sidebar_escalation_status.vue'; import IssuableLockForm from './components/lock/issuable_lock_form.vue'; import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue'; import SidebarSeverity from './components/severity/sidebar_severity.vue'; @@ -135,6 +137,8 @@ function mountAssigneesComponent() { if (!el) return; const { id, iid, fullPath, editable } = getSidebarOptions(); + const isIssuablePage = isInIssuePage() || isInIncidentPage() || isInDesignPage(); + const issuableType = isIssuablePage ? IssuableType.Issue : IssuableType.MergeRequest; // eslint-disable-next-line no-new new Vue({ el, @@ -152,21 +156,16 @@ function mountAssigneesComponent() { props: { iid: String(iid), fullPath, - issuableType: - isInIssuePage() || isInIncidentPage() || isInDesignPage() - ? IssuableType.Issue - : IssuableType.MergeRequest, + issuableType, issuableId: id, allowMultipleAssignees: !el.dataset.maxAssignees, }, scopedSlots: { - collapsed: ({ users, onClick }) => + collapsed: ({ users }) => createElement(CollapsedAssigneeList, { props: { users, - }, - nativeOn: { - click: onClick, + issuableType, }, }), }, @@ -568,6 +567,36 @@ function mountSeverityComponent() { }); } +function mountEscalationStatusComponent() { + const statusContainerEl = document.querySelector('#js-escalation-status'); + + if (!statusContainerEl) { + return false; + } + + const { issuableType } = getSidebarOptions(); + const { canUpdate, issueIid, projectPath } = statusContainerEl.dataset; + + return new Vue({ + el: statusContainerEl, + apolloProvider, + components: { + SidebarEscalationStatus, + }, + provide: { + canUpdate: parseBoolean(canUpdate), + }, + render: (createElement) => + createElement('sidebar-escalation-status', { + props: { + iid: issueIid, + issuableType, + projectPath, + }, + }), + }); +} + function mountCopyEmailComponent() { const el = document.getElementById('issuable-copy-email'); @@ -585,7 +614,7 @@ function mountCopyEmailComponent() { } const isAssigneesWidgetShown = - (isInIssuePage() || isInDesignPage()) && gon.features.issueAssigneesWidget; + (isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget; export function mountSidebar(mediator, store) { initInviteMembersModal(); @@ -619,6 +648,8 @@ export function mountSidebar(mediator, store) { mountSeverityComponent(); + mountEscalationStatusComponent(); + if (window.gon?.features?.mrAttentionRequests) { eventHub.$on('removeCurrentUserAttentionRequested', () => { mediator.removeCurrentUserAttentionRequested(); diff --git a/app/assets/javascripts/sidebar/queries/escalation_status.query.graphql b/app/assets/javascripts/sidebar/queries/escalation_status.query.graphql new file mode 100644 index 00000000000..cb7c5a0fbe7 --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/escalation_status.query.graphql @@ -0,0 +1,9 @@ +query escalationStatusQuery($fullPath: ID!, $iid: String) { + workspace: project(fullPath: $fullPath) { + id + issuable: issue(iid: $iid) { + id + escalationStatus + } + } +} diff --git a/app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql b/app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql new file mode 100644 index 00000000000..a4aff7968df --- /dev/null +++ b/app/assets/javascripts/sidebar/queries/update_escalation_status.mutation.graphql @@ -0,0 +1,10 @@ +mutation updateEscalationStatus($projectPath: ID!, $status: IssueEscalationStatus!, $iid: String!) { + issueSetEscalationStatus(input: { projectPath: $projectPath, status: $status, iid: $iid }) { + errors + clientMutationId + issue { + id + escalationStatus + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql index 81e19e48d75..7127940bb05 100644 --- a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql +++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_mr_assignees.query.graphql @@ -10,8 +10,14 @@ query getMrAssignees($fullPath: ID!, $iid: String!) { nodes { ...User ...UserAvailability + mergeRequestInteraction { + canMerge + } } } + userPermissions { + canMerge + } } } } 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 index 77140ea36d8..5fec2ccbdfb 100644 --- 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 @@ -2,21 +2,18 @@ #import "~/graphql_shared/fragments/user_availability.fragment.graphql" mutation mergeRequestSetAssignees($iid: String!, $assigneeUsernames: [String!]!, $fullPath: ID!) { - mergeRequestSetAssignees( + issuableSetAssignees: mergeRequestSetAssignees( input: { iid: $iid, assigneeUsernames: $assigneeUsernames, projectPath: $fullPath } ) { - mergeRequest { + issuable: mergeRequest { id assignees { nodes { ...User ...UserAvailability - } - } - participants { - nodes { - ...User - ...UserAvailability + mergeRequestInteraction { + canMerge + } } } } diff --git a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue index b85cae0c64f..9df5254155e 100644 --- a/app/assets/javascripts/vue_shared/components/user_select/user_select.vue +++ b/app/assets/javascripts/vue_shared/components/user_select/user_select.vue @@ -1,4 +1,5 @@ <script> +import { debounce } from 'lodash'; import { GlDropdown, GlDropdownForm, @@ -6,11 +7,14 @@ import { GlDropdownItem, GlSearchBoxByType, GlLoadingIcon, + GlTooltipDirective, } from '@gitlab/ui'; -import searchUsers from '~/graphql_shared/queries/users_search.query.graphql'; import { __ } from '~/locale'; import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; -import { ASSIGNEES_DEBOUNCE_DELAY, participantsQueries } from '~/sidebar/constants'; +import { IssuableType } from '~/issues/constants'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { participantsQueries, userSearchQueries } from '~/sidebar/constants'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; export default { i18n: { @@ -25,6 +29,9 @@ export default { SidebarParticipant, GlLoadingIcon, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { headerText: { type: String, @@ -58,13 +65,18 @@ export default { issuableType: { type: String, required: false, - default: 'issue', + default: IssuableType.Issue, }, isEditing: { type: Boolean, required: false, default: true, }, + issuableId: { + type: Number, + required: false, + default: null, + }, }, data() { return { @@ -89,28 +101,35 @@ export default { }; }, update(data) { - return data.workspace?.issuable?.participants.nodes; + return data.workspace?.issuable?.participants.nodes.map((node) => ({ + ...node, + canMerge: false, + })); }, error() { this.$emit('error'); }, }, searchUsers: { - query: searchUsers, + query() { + return userSearchQueries[this.issuableType].query; + }, variables() { - return { - fullPath: this.fullPath, - search: this.search, - first: 20, - }; + return this.searchUsersVariables; }, skip() { return !this.isEditing; }, update(data) { - return data.workspace?.users?.nodes.filter((x) => x?.user).map(({ user }) => user) || []; + return ( + data.workspace?.users?.nodes + .filter((x) => x?.user) + .map((node) => ({ + ...node.user, + canMerge: node.mergeRequestInteraction?.canMerge || false, + })) || [] + ); }, - debounce: ASSIGNEES_DEBOUNCE_DELAY, error() { this.$emit('error'); this.isSearching = false; @@ -121,6 +140,23 @@ export default { }, }, computed: { + isMergeRequest() { + return this.issuableType === IssuableType.MergeRequest; + }, + searchUsersVariables() { + const variables = { + fullPath: this.fullPath, + search: this.search, + first: 20, + }; + if (!this.isMergeRequest) { + return variables; + } + return { + ...variables, + mergeRequestId: convertToGraphQLId('MergeRequest', this.issuableId), + }; + }, isLoading() { return this.$apollo.queries.searchUsers.loading || this.$apollo.queries.participants.loading; }, @@ -135,8 +171,8 @@ export default { // TODO this de-duplication is temporary (BE fix required) // https://gitlab.com/gitlab-org/gitlab/-/issues/327822 - const mergedSearchResults = filteredParticipants - .concat(this.searchUsers) + const mergedSearchResults = this.searchUsers + .concat(filteredParticipants) .reduce( (acc, current) => (acc.some((user) => current.id === user.id) ? acc : [...acc, current]), [], @@ -179,6 +215,7 @@ export default { return this.selectedFiltered.length === 0; }, }, + 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 @@ -188,15 +225,21 @@ export default { } }, }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, methods: { selectAssignee(user) { let selected = [...this.value]; if (!this.allowMultipleAssignees) { selected = [user]; + this.$emit('input', selected); + this.$refs.dropdown.hide(); + this.$emit('toggle'); } else { selected.push(user); + this.$emit('input', selected); } - this.$emit('input', selected); }, unselect(name) { const selected = this.value.filter((user) => user.username !== name); @@ -205,6 +248,9 @@ export default { focusSearch() { this.$refs.search.focusInput(); }, + showDropdown() { + this.$refs.dropdown.show(); + }, showDivider(list) { return list.length > 0 && this.isSearchEmpty; }, @@ -216,22 +262,37 @@ export default { const currentUser = usersCopy.find((user) => user.username === this.currentUser.username); if (currentUser) { + currentUser.canMerge = this.currentUser.canMerge; const index = usersCopy.indexOf(currentUser); usersCopy.splice(0, 0, usersCopy.splice(index, 1)[0]); } return usersCopy; }, + setSearchKey(value) { + this.search = value.trim(); + }, + tooltipText(user) { + if (!this.isMergeRequest) { + return ''; + } + return user.canMerge ? '' : __('Cannot merge'); + }, }, }; </script> <template> - <gl-dropdown class="show" :text="text" @toggle="$emit('toggle')"> + <gl-dropdown ref="dropdown" :text="text" @toggle="$emit('toggle')" @shown="focusSearch"> <template #header> <p class="gl-font-weight-bold gl-text-center gl-mt-2 gl-mb-4">{{ headerText }}</p> <gl-dropdown-divider /> - <gl-search-box-by-type ref="search" v-model.trim="search" class="js-dropdown-input-field" /> + <gl-search-box-by-type + ref="search" + :value="search" + class="js-dropdown-input-field" + @input="debouncedSearchKeyUpdate" + /> </template> <gl-dropdown-form class="gl-relative gl-min-h-7"> <gl-loading-icon @@ -247,7 +308,7 @@ export default { :is-checked="selectedIsEmpty" :is-check-centered="true" data-testid="unassign" - @click="$emit('input', [])" + @click.native.capture.stop="$emit('input', [])" > <span :class="selectedIsEmpty ? 'gl-pl-0' : 'gl-pl-6'" class="gl-font-weight-bold">{{ $options.i18n.unassigned @@ -258,27 +319,44 @@ export default { <gl-dropdown-item v-for="item in selectedFiltered" :key="item.id" + v-gl-tooltip.left.viewport + :title="tooltipText(item)" + boundary="viewport" is-checked is-check-centered data-testid="selected-participant" - @click.stop="unselect(item.username)" + @click.native.capture.stop="unselect(item.username)" > - <sidebar-participant :user="item" /> + <sidebar-participant :user="item" :issuable-type="issuableType" /> </gl-dropdown-item> <template v-if="showCurrentUser"> <gl-dropdown-divider /> - <gl-dropdown-item data-testid="current-user" @click.stop="selectAssignee(currentUser)"> - <sidebar-participant :user="currentUser" class="gl-pl-6!" /> + <gl-dropdown-item + data-testid="current-user" + @click.native.capture.stop="selectAssignee(currentUser)" + > + <sidebar-participant + :user="currentUser" + :issuable-type="issuableType" + class="gl-pl-6!" + /> </gl-dropdown-item> </template> <gl-dropdown-divider v-if="showDivider(unselectedFiltered)" /> <gl-dropdown-item v-for="unselectedUser in unselectedFiltered" :key="unselectedUser.id" + v-gl-tooltip.left.viewport + :title="tooltipText(unselectedUser)" + boundary="viewport" data-testid="unselected-participant" - @click="selectAssignee(unselectedUser)" + @click.native.capture.stop="selectAssignee(unselectedUser)" > - <sidebar-participant :user="unselectedUser" class="gl-pl-6!" /> + <sidebar-participant + :user="unselectedUser" + :issuable-type="issuableType" + class="gl-pl-6!" + /> </gl-dropdown-item> <gl-dropdown-item v-if="noUsersFound" data-testid="empty-results" class="gl-pl-6!"> {{ __('No matching results') }} diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 6a27a0770c0..c00af802c06 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -108,12 +108,15 @@ .merge-icon { color: $orange-400; position: absolute; - bottom: 0; - right: 0; filter: drop-shadow(0 0 0.5px $white) drop-shadow(0 0 1px $white) drop-shadow(0 0 2px $white); } } +.assignee .merge-icon { + top: calc(50% + 0.25rem); + left: 1.275rem; +} + .reviewer .merge-icon { bottom: -3px; right: -3px; diff --git a/app/controllers/projects/ci/pipeline_editor_controller.rb b/app/controllers/projects/ci/pipeline_editor_controller.rb index 6f12e3940dd..8c6e8f0e126 100644 --- a/app/controllers/projects/ci/pipeline_editor_controller.rb +++ b/app/controllers/projects/ci/pipeline_editor_controller.rb @@ -2,7 +2,6 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController before_action :check_can_collaborate! - before_action :setup_walkthrough_experiment, only: :show before_action do push_frontend_feature_flag(:schema_linting, @project, default_enabled: :yaml) end @@ -19,11 +18,4 @@ class Projects::Ci::PipelineEditorController < Projects::ApplicationController def check_can_collaborate! render_404 unless can_collaborate_with_project?(@project) end - - def setup_walkthrough_experiment - experiment(:pipeline_editor_walkthrough, namespace: @project.namespace, sticky_to: current_user) do |e| - e.candidate {} - e.publish_to_database - end - end end diff --git a/app/controllers/projects/google_cloud/gcp_regions_controller.rb b/app/controllers/projects/google_cloud/gcp_regions_controller.rb index c0531e5d2f5..beeb91cfd80 100644 --- a/app/controllers/projects/google_cloud/gcp_regions_controller.rb +++ b/app/controllers/projects/google_cloud/gcp_regions_controller.rb @@ -12,13 +12,14 @@ class Projects::GoogleCloud::GcpRegionsController < Projects::GoogleCloud::BaseC branches = BranchesFinder.new(project.repository, params).execute(gitaly_pagination: true) tags = TagsFinder.new(project.repository, params).execute(gitaly_pagination: true) refs = (branches + tags).map(&:name) - @js_data = { + js_data = { screen: 'gcp_regions_form', availableRegions: AVAILABLE_REGIONS, refs: refs, cancelPath: project_google_cloud_index_path(project) - }.to_json - track_event('gcp_regions#index', 'form_render', @js_data) + } + @js_data = js_data.to_json + track_event('gcp_regions#index', 'form_render', js_data) end def create diff --git a/app/controllers/projects/google_cloud/revoke_oauth_controller.rb b/app/controllers/projects/google_cloud/revoke_oauth_controller.rb new file mode 100644 index 00000000000..03d1474707b --- /dev/null +++ b/app/controllers/projects/google_cloud/revoke_oauth_controller.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +class Projects::GoogleCloud::RevokeOauthController < Projects::GoogleCloud::BaseController + before_action :validate_gcp_token! + + def create + google_api_client = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) + response = google_api_client.revoke_authorizations + + if response.success? + status = 'success' + redirect_message = { notice: s_('GoogleCloud|Google OAuth2 token revocation requested') } + else + status = 'failed' + redirect_message = { alert: s_('GoogleCloud|Google OAuth2 token revocation request failed') } + end + + session.delete(GoogleApi::CloudPlatform::Client.session_key_for_token) + track_event('revoke_oauth#create', 'create', status) + + redirect_to project_google_cloud_index_path(project), redirect_message + end +end diff --git a/app/controllers/projects/google_cloud/service_accounts_controller.rb b/app/controllers/projects/google_cloud/service_accounts_controller.rb index 9c4dd35a6f5..5d8b2030d5c 100644 --- a/app/controllers/projects/google_cloud/service_accounts_controller.rb +++ b/app/controllers/projects/google_cloud/service_accounts_controller.rb @@ -17,14 +17,15 @@ class Projects::GoogleCloud::ServiceAccountsController < Projects::GoogleCloud:: branches = BranchesFinder.new(project.repository, params).execute(gitaly_pagination: true) tags = TagsFinder.new(project.repository, params).execute(gitaly_pagination: true) refs = (branches + tags).map(&:name) - @js_data = { + js_data = { screen: 'service_accounts_form', gcpProjects: gcp_projects, refs: refs, cancelPath: project_google_cloud_index_path(project) - }.to_json + } + @js_data = js_data.to_json - track_event('service_accounts#index', 'form_success', @js_data) + track_event('service_accounts#index', 'form_success', js_data) end rescue Google::Apis::ClientError => error handle_gcp_error('service_accounts#index', error) diff --git a/app/controllers/projects/google_cloud_controller.rb b/app/controllers/projects/google_cloud_controller.rb index e52e14a75bc..49bb4bec859 100644 --- a/app/controllers/projects/google_cloud_controller.rb +++ b/app/controllers/projects/google_cloud_controller.rb @@ -4,7 +4,7 @@ class Projects::GoogleCloudController < Projects::GoogleCloud::BaseController GCP_REGION_CI_VAR_KEY = 'GCP_REGION' def index - @js_data = { + js_data = { screen: 'home', serviceAccounts: GoogleCloud::ServiceAccountsService.new(project).find_for_project, createServiceAccountUrl: project_google_cloud_service_accounts_path(project), @@ -12,9 +12,11 @@ class Projects::GoogleCloudController < Projects::GoogleCloud::BaseController enableCloudStorageUrl: project_google_cloud_deployments_cloud_storage_path(project), emptyIllustrationUrl: ActionController::Base.helpers.image_path('illustrations/pipelines_empty.svg'), configureGcpRegionsUrl: project_google_cloud_gcp_regions_path(project), - gcpRegions: gcp_regions - }.to_json - track_event('google_cloud#index', 'index', @js_data) + gcpRegions: gcp_regions, + revokeOauthUrl: revoke_oauth_url + } + @js_data = js_data.to_json + track_event('google_cloud#index', 'index', js_data) end private @@ -23,4 +25,10 @@ class Projects::GoogleCloudController < Projects::GoogleCloud::BaseController list = ::Ci::VariablesFinder.new(project, { key: GCP_REGION_CI_VAR_KEY }).execute list.map { |variable| { gcp_region: variable.value, environment: variable.environment_scope } } end + + def revoke_oauth_url + google_token_valid = GoogleApi::CloudPlatform::Client.new(token_in_session, nil) + .validate_token(expires_at_in_session) + google_token_valid ? project_google_cloud_revoke_oauth_index_path(project) : nil + end end diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb index 6f67b07c6a0..293581a6744 100644 --- a/app/controllers/projects/incidents_controller.rb +++ b/app/controllers/projects/incidents_controller.rb @@ -48,3 +48,5 @@ class Projects::IncidentsController < Projects::ApplicationController IssueSerializer.new(current_user: current_user, project: incident.project) end end + +Projects::IncidentsController.prepend_mod diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index 04f311f58e9..5259bf90dd0 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -43,6 +43,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo push_frontend_feature_flag(:rebase_without_ci_ui, project, default_enabled: :yaml) push_frontend_feature_flag(:markdown_continue_lists, project, default_enabled: :yaml) push_frontend_feature_flag(:secure_vulnerability_training, project, default_enabled: :yaml) + push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml) # Usage data feature flags push_frontend_feature_flag(:users_expanding_widgets_usage_data, project, default_enabled: :yaml) push_frontend_feature_flag(:diff_settings_usage_data, default_enabled: :yaml) diff --git a/app/helpers/listbox_helper.rb b/app/helpers/listbox_helper.rb index d24680bc0b0..16caf862c7b 100644 --- a/app/helpers/listbox_helper.rb +++ b/app/helpers/listbox_helper.rb @@ -16,8 +16,10 @@ module ListboxHelper # the sort key), `text` is the user-facing string for the item, and `href` is # the path to redirect to when that item is selected. # - # The `selected` parameter is the currently selected `value`, and must - # correspond to one of the `items`, or be `nil`. When `selected.nil?`, the first item is selected. + # The `selected` parameter is the currently selected `value`, and should + # correspond to one of the `items`, or be `nil`. When `selected.nil?` or + # a value which does not correspond to one of the items, the first item is + # selected. # # The final parameter `html_options` applies arbitrary attributes to the # returned tag. Some of these are passed to the underlying Vue component as @@ -37,9 +39,12 @@ module ListboxHelper webpack_bundle_tag 'redirect_listbox' end - selected ||= items.first[:value] selected_option = items.find { |opt| opt[:value] == selected } - raise ArgumentError, "cannot find #{selected} in #{items}" unless selected_option + + unless selected_option + selected_option = items.first + selected = selected_option[:value] + end button = button_tag(type: :button, class: DROPDOWN_BUTTON_CLASSES) do content_tag(:span, selected_option[:text], class: DROPDOWN_INNER_CLASS) + diff --git a/app/models/project_team.rb b/app/models/project_team.rb index d5e0d112aeb..4b89d95c1a3 100644 --- a/app/models/project_team.rb +++ b/app/models/project_team.rb @@ -198,31 +198,15 @@ class ProjectTeam end def contribution_check_for_user_ids(user_ids) - user_ids = user_ids.uniq - key = "contribution_check_for_users:#{project.id}" - - Gitlab::SafeRequestStore[key] ||= {} - contributors = Gitlab::SafeRequestStore[key] || {} - - user_ids -= contributors.keys - - return contributors if user_ids.empty? - - resource_contributors = project.merge_requests - .merged - .where(author_id: user_ids, target_branch: project.default_branch.to_s) - .pluck(:author_id) - .product([true]).to_h - - contributors.merge!(resource_contributors) - - missing_resource_ids = user_ids - resource_contributors.keys - - missing_resource_ids.each do |resource_id| - contributors[resource_id] = false + Gitlab::SafeRequestLoader.execute(resource_key: "contribution_check_for_users:#{project.id}", + resource_ids: user_ids, + default_value: false) do |user_ids| + project.merge_requests + .merged + .where(author_id: user_ids, target_branch: project.default_branch.to_s) + .pluck(:author_id) + .product([true]).to_h end - - contributors end def contributor?(user_id) diff --git a/app/models/wiki.rb b/app/models/wiki.rb index 307f31edfef..622070abd88 100644 --- a/app/models/wiki.rb +++ b/app/models/wiki.rb @@ -87,8 +87,7 @@ class Wiki end def create_wiki_repository - repository.create_if_not_exists - change_head_to_default_branch + repository.create_if_not_exists(default_branch) raise CouldNotCreateWikiError unless repository_exists? rescue StandardError => err @@ -322,16 +321,6 @@ class Wiki def default_message(action, title) "#{user.username} #{action} page: #{title}" end - - def change_head_to_default_branch - # If the wiki has commits in the 'HEAD' branch means that the current - # HEAD is pointing to the right branch. If not, it could mean that either - # the repo has just been created or that 'HEAD' is pointing - # to the wrong branch and we need to rewrite it - return if repository.raw_repository.commit_count('HEAD') != 0 - - repository.raw_repository.write_ref('HEAD', "refs/heads/#{default_branch}") - end end Wiki.prepend_mod_with('Wiki') diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 1dcf4409048..7a49ad3d4aa 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -81,7 +81,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy condition(:crm_enabled, score: 0, scope: :subject) { Feature.enabled?(:customer_relations, @subject) && @subject.crm_enabled? } condition(:group_runner_registration_allowed) do - Feature.disabled?(:runner_registration_control) || Gitlab::CurrentSettings.valid_runner_registrars.include?('group') + Feature.disabled?(:runner_registration_control, default_enabled: :yaml) || Gitlab::CurrentSettings.valid_runner_registrars.include?('group') end rule { can?(:read_group) & design_management_enabled }.policy do diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index e7b63d5e17f..09085bef9f0 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -195,7 +195,7 @@ class ProjectPolicy < BasePolicy end condition(:project_runner_registration_allowed) do - Feature.disabled?(:runner_registration_control) || Gitlab::CurrentSettings.valid_runner_registrars.include?('project') + Feature.disabled?(:runner_registration_control, default_enabled: :yaml) || Gitlab::CurrentSettings.valid_runner_registrars.include?('project') end # `:read_project` may be prevented in EE, but `:read_project_for_iids` should diff --git a/app/services/ci/runners/register_runner_service.rb b/app/services/ci/runners/register_runner_service.rb index 196d2de1a65..7978d094d9b 100644 --- a/app/services/ci/runners/register_runner_service.rb +++ b/app/services/ci/runners/register_runner_service.rb @@ -47,7 +47,7 @@ module Ci end def runner_registrar_valid?(type) - Feature.disabled?(:runner_registration_control) || Gitlab::CurrentSettings.valid_runner_registrars.include?(type) + Feature.disabled?(:runner_registration_control, default_enabled: :yaml) || Gitlab::CurrentSettings.valid_runner_registrars.include?(type) end def token_scope diff --git a/app/services/issuable_links/create_service.rb b/app/services/issuable_links/create_service.rb index 81685f81afa..64a64f50ded 100644 --- a/app/services/issuable_links/create_service.rb +++ b/app/services/issuable_links/create_service.rb @@ -36,6 +36,20 @@ module IssuableLinks success end + # rubocop: disable CodeReuse/ActiveRecord + def relate_issuables(referenced_issuable) + link = link_class.find_or_initialize_by(source: issuable, target: referenced_issuable) + + set_link_type(link) + + if link.changed? && link.save + create_notes(referenced_issuable) + end + + link + end + # rubocop: enable CodeReuse/ActiveRecord + private def render_conflict_error? @@ -96,6 +110,23 @@ module IssuableLinks {} end + def issuables_assigned_message + _('%{issuable}(s) already assigned' % { issuable: target_issuable_type.capitalize }) + end + + def issuables_not_found_message + _('No matching %{issuable} found. Make sure that you are adding a valid %{issuable} URL.' % { issuable: target_issuable_type }) + end + + def target_issuable_type + :issue + end + + def create_notes(referenced_issuable) + SystemNoteService.relate_issuable(issuable, referenced_issuable, current_user) + SystemNoteService.relate_issuable(referenced_issuable, issuable, current_user) + end + def linkable_issuables(objects) raise NotImplementedError end @@ -104,16 +135,12 @@ module IssuableLinks raise NotImplementedError end - def relate_issuables(referenced_object) + def link_class raise NotImplementedError end - def issuables_assigned_message - _("Issue(s) already assigned") - end - - def issuables_not_found_message - _("No matching issue found. Make sure that you are adding a valid issue URL.") + def set_link_type(_link) + # no-op end end end diff --git a/app/services/issue_links/create_service.rb b/app/services/issue_links/create_service.rb index a022d3e0bcf..1c6621ce0a1 100644 --- a/app/services/issue_links/create_service.rb +++ b/app/services/issue_links/create_service.rb @@ -2,44 +2,25 @@ module IssueLinks class CreateService < IssuableLinks::CreateService - # rubocop: disable CodeReuse/ActiveRecord - def relate_issuables(referenced_issue) - link = IssueLink.find_or_initialize_by(source: issuable, target: referenced_issue) - - set_link_type(link) - - if link.changed? && link.save - create_notes(referenced_issue) - end - - link - end - # rubocop: enable CodeReuse/ActiveRecord - def linkable_issuables(issues) @linkable_issuables ||= begin issues.select { |issue| can?(current_user, :admin_issue_link, issue) } end end - def create_notes(referenced_issue) - SystemNoteService.relate_issue(issuable, referenced_issue, current_user) - SystemNoteService.relate_issue(referenced_issue, issuable, current_user) - end - def previous_related_issuables @related_issues ||= issuable.related_issues(current_user).to_a end private - def set_link_type(_link) - # EE only - end - def track_event track_incident_action(current_user, issuable, :incident_relate) end + + def link_class + IssueLink + end end end diff --git a/app/services/merge_requests/approval_service.rb b/app/services/merge_requests/approval_service.rb index 527ed9d45b7..37c2676e51c 100644 --- a/app/services/merge_requests/approval_service.rb +++ b/app/services/merge_requests/approval_service.rb @@ -11,6 +11,7 @@ module MergeRequests reset_approvals_cache(merge_request) create_event(merge_request) + stream_audit_event(merge_request) create_approval_note(merge_request) mark_pending_todos_as_done(merge_request) execute_approval_hooks(merge_request, current_user) @@ -52,6 +53,10 @@ module MergeRequests def create_event(merge_request) event_service.approve_mr(merge_request, current_user) end + + def stream_audit_event(merge_request) + # Defined in EE + end end end diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb index 8d77f03c0d9..9db39a5e174 100644 --- a/app/services/system_note_service.rb +++ b/app/services/system_note_service.rb @@ -49,8 +49,8 @@ module SystemNoteService ::SystemNotes::IssuablesService.new(noteable: issuable, project: project, author: author).change_issuable_contacts(added_count, removed_count) end - def relate_issue(noteable, noteable_ref, user) - ::SystemNotes::IssuablesService.new(noteable: noteable, project: noteable.project, author: user).relate_issue(noteable_ref) + def relate_issuable(noteable, noteable_ref, user) + ::SystemNotes::IssuablesService.new(noteable: noteable, project: noteable.project, author: user).relate_issuable(noteable_ref) end def unrelate_issuable(noteable, noteable_ref, user) diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index ce592f8d4bb..89212288a6b 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -10,8 +10,9 @@ module SystemNotes # "marked this issue as related to gitlab-foss#9001" # # Returns the created Note object - def relate_issue(noteable_ref) - body = "marked this issue as related to #{noteable_ref.to_reference(noteable.project)}" + def relate_issuable(noteable_ref) + issuable_type = noteable.to_ability_name.humanize(capitalize: false) + body = "marked this #{issuable_type} as related to #{noteable_ref.to_reference(noteable.resource_parent)}" issue_activity_counter.track_issue_related_action(author: author) if noteable.is_a?(Issue) diff --git a/app/views/admin/application_settings/ci_cd.html.haml b/app/views/admin/application_settings/ci_cd.html.haml index 18ec43407c3..762dba69e6a 100644 --- a/app/views/admin/application_settings/ci_cd.html.haml +++ b/app/views/admin/application_settings/ci_cd.html.haml @@ -39,7 +39,7 @@ .settings-content = render 'registry' -- if Feature.enabled?(:runner_registration_control) +- if Feature.enabled?(:runner_registration_control, default_enabled: :yaml) %section.settings.as-runner.no-animate#js-runner-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4 diff --git a/app/views/clusters/clusters/_cluster_list.html.haml b/app/views/clusters/clusters/_cluster_list.html.haml deleted file mode 100644 index 8d92dc30a76..00000000000 --- a/app/views/clusters/clusters/_cluster_list.html.haml +++ /dev/null @@ -1,10 +0,0 @@ -- if !clusters.empty? - .top-area.adjust - .gl-display-block.gl-text-right.gl-my-4.gl-w-full - - if clusterable.can_add_cluster? - = link_to s_('ClusterIntegration|Connect cluster with certificate'), clusterable.new_path, class: 'btn gl-button btn-confirm js-add-cluster gl-py-2', data: { qa_selector: 'integrate_kubernetes_cluster_button' } - - else - %span.btn.gl-button.btn-confirm.js-add-cluster.disabled.gl-py-2 - = s_("ClusterIntegration|Connect cluster with certificate") - -.js-clusters-main-view{ data: js_clusters_list_data(clusterable) } diff --git a/app/views/clusters/clusters/index.html.haml b/app/views/clusters/clusters/index.html.haml index 457e34b306a..eec44945a77 100644 --- a/app/views/clusters/clusters/index.html.haml +++ b/app/views/clusters/clusters/index.html.haml @@ -9,4 +9,4 @@ .js-clusters-main-view{ data: js_clusters_data(clusterable) } - else - = render 'cluster_list', clusters: @clusters + .js-clusters-main-view{ data: js_clusters_list_data(clusterable) } diff --git a/app/views/projects/merge_requests/creations/_new_compare.html.haml b/app/views/projects/merge_requests/creations/_new_compare.html.haml index ea778517374..e2ac8ef5abc 100644 --- a/app/views/projects/merge_requests/creations/_new_compare.html.haml +++ b/app/views/projects/merge_requests/creations/_new_compare.html.haml @@ -29,8 +29,7 @@ = dropdown_content = dropdown_loading .card-footer - .text-center - .js-source-loading.mt-1.gl-spinner + = gl_loading_icon(css_class: 'js-source-loading gl-my-3') %ul.list-unstyled.mr_source_commit .col-lg-6 @@ -58,8 +57,7 @@ = dropdown_content = dropdown_loading .card-footer - .text-center - .js-target-loading.mt-1.gl-spinner + = gl_loading_icon(css_class: 'js-target-loading gl-my-3') %ul.list-unstyled.mr_target_commit - if @merge_request.errors.any? diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml index 960b1d67610..3a62c6f41cc 100644 --- a/app/views/projects/settings/_general.html.haml +++ b/app/views/projects/settings/_general.html.haml @@ -23,7 +23,7 @@ .row .form-group.col-md-9 = f.label :description, _('Project description (optional)'), class: 'label-bold' - = f.text_area :description, class: 'form-control gl-form-input', rows: 3, maxlength: 250 + = f.text_area :description, class: 'form-control gl-form-input', rows: 3 .row= render_if_exists 'projects/classification_policy_settings', f: f diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 72fa979392e..37d31515307 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -27,7 +27,7 @@ - if issuable_sidebar[:supports_escalation] .block.escalation-status{ data: { testid: 'escalation_status_container' } } - #js-escalation-status{ data: { can_edit: issuable_sidebar.dig(:current_user, :can_update_escalation_status).to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } } + #js-escalation-status{ data: { can_update: issuable_sidebar.dig(:current_user, :can_update_escalation_status).to_s, project_path: issuable_sidebar[:project_full_path], issue_iid: issuable_sidebar[:iid] } } = render_if_exists 'shared/issuable/sidebar_escalation_policy', issuable_sidebar: issuable_sidebar - if @project.group.present? |