diff options
Diffstat (limited to 'app')
34 files changed, 726 insertions, 91 deletions
diff --git a/app/assets/javascripts/grafana_integration/store/actions.js b/app/assets/javascripts/grafana_integration/store/actions.js index 36a09831c74..76e21f09719 100644 --- a/app/assets/javascripts/grafana_integration/store/actions.js +++ b/app/assets/javascripts/grafana_integration/store/actions.js @@ -29,7 +29,7 @@ export const updateGrafanaIntegration = ({ state, dispatch }) => export const receiveGrafanaIntegrationUpdateSuccess = () => { /** * The operations_controller currently handles successful requests - * by creating a alert banner messsage to notify the user. + * by creating an alert banner message to notify the user. */ refreshCurrentPage(); }; diff --git a/app/assets/javascripts/operation_settings/store/actions.js b/app/assets/javascripts/operation_settings/store/actions.js index 5c870fd5f55..7fa79da59c4 100644 --- a/app/assets/javascripts/operation_settings/store/actions.js +++ b/app/assets/javascripts/operation_settings/store/actions.js @@ -26,7 +26,7 @@ export const saveChanges = ({ state, dispatch }) => export const receiveSaveChangesSuccess = () => { /** * The operations_controller currently handles successful requests - * by creating a alert banner messsage to notify the user. + * by creating an alert banner message to notify the user. */ refreshCurrentPage(); }; diff --git a/app/assets/javascripts/pages/projects/settings/repository/form.js b/app/assets/javascripts/pages/projects/settings/repository/form.js index 380091a3501..f64de693188 100644 --- a/app/assets/javascripts/pages/projects/settings/repository/form.js +++ b/app/assets/javascripts/pages/projects/settings/repository/form.js @@ -10,8 +10,8 @@ import ProtectedTagEditList from '~/protected_tags/protected_tag_edit_list'; import initSettingsPanels from '~/settings_panels'; export default () => { - new ProtectedTagCreate(); - new ProtectedTagEditList(); + new ProtectedTagCreate({ hasLicense: false }); + new ProtectedTagEditList({ hasLicense: false }); initDeployKeys(); initSettingsPanels(); new ProtectedBranchCreate({ hasLicense: false }); diff --git a/app/assets/javascripts/projects/settings/access_dropdown.js b/app/assets/javascripts/projects/settings/access_dropdown.js index 52b8e7e1cd5..71c9e580420 100644 --- a/app/assets/javascripts/projects/settings/access_dropdown.js +++ b/app/assets/javascripts/projects/settings/access_dropdown.js @@ -469,6 +469,14 @@ export default class AccessDropdown { } } + if (this.accessLevel === ACCESS_LEVELS.CREATE && deployKeys.length) { + consolidatedData = consolidatedData.concat( + [{ type: 'divider' }], + [{ type: 'header', content: s__('AccessDropdown|Deploy Keys') }], + deployKeys, + ); + } + return consolidatedData; } @@ -506,7 +514,10 @@ export default class AccessDropdown { break; case LEVEL_TYPES.DEPLOY_KEY: groupRowEl = - this.accessLevel === ACCESS_LEVELS.PUSH ? this.deployKeyRowHtml(item, isActive) : ''; + this.accessLevel === ACCESS_LEVELS.PUSH || this.accessLevel === ACCESS_LEVELS.CREATE + ? this.deployKeyRowHtml(item, isActive) + : ''; + break; case LEVEL_TYPES.GROUP: groupRowEl = this.groupRowHtml(item, isActive); diff --git a/app/assets/javascripts/projects/settings/components/access_dropdown.vue b/app/assets/javascripts/projects/settings/components/access_dropdown.vue index 627914ae2b1..08a1c586f69 100644 --- a/app/assets/javascripts/projects/settings/components/access_dropdown.vue +++ b/app/assets/javascripts/projects/settings/components/access_dropdown.vue @@ -86,7 +86,10 @@ export default { return groupBy(this.preselectedItems, 'type'); }, showDeployKeys() { - return this.accessLevel === ACCESS_LEVELS.PUSH && this.deployKeys.length; + return ( + (this.accessLevel === ACCESS_LEVELS.PUSH || this.accessLevel === ACCESS_LEVELS.CREATE) && + this.deployKeys.length + ); }, toggleLabel() { const counts = Object.entries(this.selected).reduce((acc, [key, value]) => { diff --git a/app/assets/javascripts/projects/settings/constants.js b/app/assets/javascripts/projects/settings/constants.js index 9cf1afd334f..595cbc9c991 100644 --- a/app/assets/javascripts/projects/settings/constants.js +++ b/app/assets/javascripts/projects/settings/constants.js @@ -17,6 +17,7 @@ export const LEVEL_ID_PROP = { export const ACCESS_LEVELS = { MERGE: 'merge_access_levels', PUSH: 'push_access_levels', + CREATE: 'create_access_levels', }; export const ACCESS_LEVEL_NONE = 0; diff --git a/app/assets/javascripts/protected_tags/constants.js b/app/assets/javascripts/protected_tags/constants.js index 3e71ba62877..758b820c4c4 100644 --- a/app/assets/javascripts/protected_tags/constants.js +++ b/app/assets/javascripts/protected_tags/constants.js @@ -1,3 +1,14 @@ import { s__ } from '~/locale'; export const FAILED_TO_UPDATE_TAG_MESSAGE = s__('ProjectSettings|Failed to update tag!'); + +export const ACCESS_LEVELS = { + CREATE: 'create_access_levels', +}; + +export const LEVEL_TYPES = { + ROLE: 'role', + USER: 'user', + GROUP: 'group', + DEPLOY_KEY: 'deploy_key', +}; diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js index 75fd11cd074..365b9a3b142 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_create.js +++ b/app/assets/javascripts/protected_tags/protected_tag_create.js @@ -1,12 +1,21 @@ import $ from 'jquery'; -import { __ } from '~/locale'; -import CreateItemDropdown from '../create_item_dropdown'; -import ProtectedTagAccessDropdown from './protected_tag_access_dropdown'; +import CreateItemDropdown from '~/create_item_dropdown'; +import { createAlert } from '~/alert'; +import axios from '~/lib/utils/axios_utils'; +import { s__, __ } from '~/locale'; +import AccessDropdown from '~/projects/settings/access_dropdown'; +import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; export default class ProtectedTagCreate { - constructor() { + constructor({ hasLicense }) { + this.hasLicense = hasLicense; this.$form = $('.js-new-protected-tag'); this.buildDropdowns(); + this.bindEvents(); + } + + bindEvents() { + this.$form.on('submit', this.onFormSubmit.bind(this)); } buildDropdowns() { @@ -16,15 +25,14 @@ export default class ProtectedTagCreate { this.onSelectCallback = this.onSelect.bind(this); // Allowed to Create dropdown - this.protectedTagAccessDropdown = new ProtectedTagAccessDropdown({ + this.protectedTagAccessDropdown = new AccessDropdown({ $dropdown: $allowedToCreateDropdown, - data: gon.create_access_levels, + accessLevelsData: gon.create_access_levels, onSelect: this.onSelectCallback, + accessLevel: ACCESS_LEVELS.CREATE, + hasLicense: this.hasLicense, }); - // Select default - $allowedToCreateDropdown.data('deprecatedJQueryDropdown').selectRowAtIndex(0); - // Protected tag dropdown this.createItemDropdown = new CreateItemDropdown({ $dropdown: this.$form.find('.js-protected-tag-select'), @@ -39,7 +47,7 @@ export default class ProtectedTagCreate { onSelect() { // Enable submit button const $tagInput = this.$form.find('input[name="protected_tag[name]"]'); - const $allowedToCreateInput = this.$form.find('#create_access_levels_attributes'); + const $allowedToCreateInput = this.protectedTagAccessDropdown.getSelectedItems(); this.$form .find('button[type="submit"]') @@ -49,4 +57,57 @@ export default class ProtectedTagCreate { static getProtectedTags(term, callback) { callback(gon.open_tags); } + + getFormData() { + const formData = { + authenticity_token: this.$form.find('input[name="authenticity_token"]').val(), + protected_tag: { + name: this.$form.find('input[name="protected_tag[name]"]').val(), + }, + }; + + Object.keys(ACCESS_LEVELS).forEach((level) => { + const accessLevel = ACCESS_LEVELS[level]; + const selectedItems = this.protectedTagAccessDropdown.getSelectedItems(); + const levelAttributes = []; + + selectedItems.forEach((item) => { + if (item.type === LEVEL_TYPES.USER) { + levelAttributes.push({ + user_id: item.user_id, + }); + } else if (item.type === LEVEL_TYPES.ROLE) { + levelAttributes.push({ + access_level: item.access_level, + }); + } else if (item.type === LEVEL_TYPES.GROUP) { + levelAttributes.push({ + group_id: item.group_id, + }); + } else if (item.type === LEVEL_TYPES.DEPLOY_KEY) { + levelAttributes.push({ + deploy_key_id: item.deploy_key_id, + }); + } + }); + + formData.protected_tag[`${accessLevel}_attributes`] = levelAttributes; + }); + + return formData; + } + + onFormSubmit(e) { + e.preventDefault(); + + axios[this.$form.attr('method')](this.$form.attr('action'), this.getFormData()) + .then(() => { + window.location.reload(); + }) + .catch(() => + createAlert({ + message: s__('ProjectSettings|Failed to protect the tag'), + }), + ); + } } diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.js b/app/assets/javascripts/protected_tags/protected_tag_edit.js index 392cf00d902..4fa3ac3be4b 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_edit.js +++ b/app/assets/javascripts/protected_tags/protected_tag_edit.js @@ -1,57 +1,115 @@ +import { find } from 'lodash'; import { createAlert } from '~/alert'; -import axios from '../lib/utils/axios_utils'; -import { FAILED_TO_UPDATE_TAG_MESSAGE } from './constants'; -import ProtectedTagAccessDropdown from './protected_tag_access_dropdown'; +import axios from '~/lib/utils/axios_utils'; +import AccessDropdown from '~/projects/settings/access_dropdown'; +import { ACCESS_LEVELS, LEVEL_TYPES, FAILED_TO_UPDATE_TAG_MESSAGE } from './constants'; export default class ProtectedTagEdit { constructor(options) { + this.hasLicense = options.hasLicense; + this.hasChanges = false; this.$wrap = options.$wrap; this.$allowedToCreateDropdownButton = this.$wrap.find('.js-allowed-to-create'); - this.onSelectCallback = this.onSelect.bind(this); + + this.$allowedToCreateDropdownContainer = this.$allowedToCreateDropdownButton.closest( + '.create_access_levels-container', + ); this.buildDropdowns(); } buildDropdowns() { // Allowed to create dropdown - this.protectedTagAccessDropdown = new ProtectedTagAccessDropdown({ + this.protectedTagAccessDropdown = new AccessDropdown({ + accessLevel: ACCESS_LEVELS.CREATE, + accessLevelsData: gon.create_access_levels, $dropdown: this.$allowedToCreateDropdownButton, - data: gon.create_access_levels, - onSelect: this.onSelectCallback, + onSelect: this.onSelectOption.bind(this), + onHide: this.onDropdownHide.bind(this), + hasLicense: this.hasLicense, }); } - onSelect() { - const $allowedToCreateInput = this.$wrap.find( - `input[name="${this.$allowedToCreateDropdownButton.data('fieldName')}"]`, - ); + onSelectOption() { + this.hasChanges = true; + } - // Do not update if one dropdown has not selected any option - if (!$allowedToCreateInput.length) return; + onDropdownHide() { + if (!this.hasChanges) { + return; + } - this.$allowedToCreateDropdownButton.disable(); + this.hasChanges = true; + this.updatePermissions(); + } + + updatePermissions() { + const formData = Object.keys(ACCESS_LEVELS).reduce((acc, level) => { + const accessLevelName = ACCESS_LEVELS[level]; + const inputData = this.protectedTagAccessDropdown.getInputData(accessLevelName); + acc[`${accessLevelName}_attributes`] = inputData; + + return acc; + }, {}); axios .patch(this.$wrap.data('url'), { - protected_tag: { - create_access_levels_attributes: [ - { - id: this.$allowedToCreateDropdownButton.data('accessLevelId'), - access_level: $allowedToCreateInput.val(), - }, - ], - }, + protected_tag: formData, }) - .then(() => { - this.$allowedToCreateDropdownButton.enable(); + .then(({ data }) => { + this.hasChanges = false; + + Object.keys(ACCESS_LEVELS).forEach((level) => { + const accessLevelName = ACCESS_LEVELS[level]; + + // The data coming from server will be the new persisted *state* for each dropdown + this.setSelectedItemsToDropdown(data[accessLevelName], `${accessLevelName}_dropdown`); + }); }) .catch(() => { - this.$allowedToCreateDropdownButton.enable(); - window.scrollTo({ top: 0, behavior: 'smooth' }); createAlert({ message: FAILED_TO_UPDATE_TAG_MESSAGE, }); }); } + + setSelectedItemsToDropdown(items = []) { + const itemsToAdd = items.map((currentItem) => { + if (currentItem.user_id) { + // Do this only for users for now + // get the current data for selected items + const selectedItems = this.protectedTagAccessDropdown.getSelectedItems(); + const currentSelectedItem = find(selectedItems, { + user_id: currentItem.user_id, + }); + + return { + id: currentItem.id, + user_id: currentItem.user_id, + type: LEVEL_TYPES.USER, + persisted: true, + name: currentSelectedItem.name, + username: currentSelectedItem.username, + avatar_url: currentSelectedItem.avatar_url, + }; + } else if (currentItem.group_id) { + return { + id: currentItem.id, + group_id: currentItem.group_id, + type: LEVEL_TYPES.GROUP, + persisted: true, + }; + } + + return { + id: currentItem.id, + access_level: currentItem.access_level, + type: LEVEL_TYPES.ROLE, + persisted: true, + }; + }); + + this.protectedTagAccessDropdown.setSelectedItems(itemsToAdd); + } } diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit_list.js b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js index b35bf4d4606..8ceb970bf03 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_edit_list.js +++ b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js @@ -4,7 +4,8 @@ import $ from 'jquery'; import ProtectedTagEdit from './protected_tag_edit'; export default class ProtectedTagEditList { - constructor() { + constructor(options) { + this.hasLicense = options.hasLicense; this.$wrap = $('.protected-tags-list'); this.initEditForm(); } @@ -13,6 +14,7 @@ export default class ProtectedTagEditList { this.$wrap.find('.js-protected-tag-edit-form').each((i, el) => { new ProtectedTagEdit({ $wrap: $(el), + hasLicense: this.hasLicense, }); }); } diff --git a/app/assets/javascripts/repository/components/fork_info.vue b/app/assets/javascripts/repository/components/fork_info.vue index 51a83e5df8a..1a834ba1d82 100644 --- a/app/assets/javascripts/repository/components/fork_info.vue +++ b/app/assets/javascripts/repository/components/fork_info.vue @@ -1,8 +1,16 @@ <script> -import { GlIcon, GlLink, GlSkeletonLoader, GlSprintf } from '@gitlab/ui'; +import { GlIcon, GlLink, GlSkeletonLoader, GlLoadingIcon, GlSprintf, GlButton } from '@gitlab/ui'; import { s__, sprintf, n__ } from '~/locale'; import { createAlert } from '~/alert'; +import syncForkMutation from '~/repository/mutations/sync_fork.mutation.graphql'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { + POLLING_INTERVAL_DEFAULT, + POLLING_INTERVAL_BACKOFF, + FIVE_MINUTES_IN_MS, +} from '../constants'; import forkDetailsQuery from '../queries/fork_details.query.graphql'; +import ConflictsModal from './fork_sync_conflicts_modal.vue'; export const i18n = { forkedFrom: s__('ForkedFromProjectPath|Forked from'), @@ -12,7 +20,9 @@ export const i18n = { behind: s__('ForksDivergence|%{behindLinkStart}%{behind} %{commit_word} behind%{behindLinkEnd}'), ahead: s__('ForksDivergence|%{aheadLinkStart}%{ahead} %{commit_word} ahead%{aheadLinkEnd} of'), behindAhead: s__('ForksDivergence|%{messages} the upstream repository.'), + limitedVisibility: s__('ForksDivergence|Source project has a limited visibility.'), error: s__('ForksDivergence|Failed to fetch fork details. Try again later.'), + sync: s__('ForksDivergence|Update fork'), }; export default { @@ -20,17 +30,19 @@ export default { components: { GlIcon, GlLink, + GlButton, GlSprintf, GlSkeletonLoader, + ConflictsModal, + GlLoadingIcon, }, + mixins: [glFeatureFlagMixin()], apollo: { project: { query: forkDetailsQuery, + notifyOnNetworkStatusChange: true, variables() { - return { - projectPath: this.projectPath, - ref: this.selectedBranch, - }; + return this.forkDetailsQueryVariables; }, skip() { return !this.sourceName; @@ -42,6 +54,12 @@ export default { error, }); }, + result({ loading }) { + this.handlePolingInterval(loading); + }, + pollInterval() { + return this.pollInterval; + }, }, }, props: { @@ -53,6 +71,11 @@ export default { type: String, required: true, }, + sourceDefaultBranch: { + type: String, + required: false, + default: '', + }, sourceName: { type: String, required: false, @@ -76,18 +99,33 @@ export default { }, data() { return { - project: { - forkDetails: { - ahead: null, - behind: null, - }, - }, + project: {}, + currentPollInterval: null, + isSyncTriggered: false, }; }, computed: { + forkDetailsQueryVariables() { + return { + projectPath: this.projectPath, + ref: this.selectedBranch, + }; + }, + pollInterval() { + return this.isSyncing ? this.currentPollInterval : 0; + }, isLoading() { return this.$apollo.queries.project.loading; }, + forkDetails() { + return this.project?.forkDetails; + }, + hasConflicts() { + return this.forkDetails?.hasConflicts; + }, + isSyncing() { + return this.forkDetails?.isSyncing; + }, ahead() { return this.project?.forkDetails?.ahead; }, @@ -107,7 +145,10 @@ export default { }); }, isUnknownDivergence() { - return (!this.ahead && this.ahead !== 0) || (!this.behind && this.behind !== 0); + return this.sourceName && this.ahead === null && this.behind === null; + }, + isUpToDate() { + return this.ahead === 0 && this.behind === 0; }, behindAheadMessage() { const messages = []; @@ -122,7 +163,16 @@ export default { hasBehindAheadMessage() { return this.behindAheadMessage.length > 0; }, + isSyncButtonAvailable() { + return ( + this.glFeatures.synchronizeFork && + ((this.sourceName && this.forkDetails && this.behind) || this.isUnknownDivergence) + ); + }, forkDivergenceMessage() { + if (!this.forkDetails) { + return this.$options.i18n.limitedVisibility; + } if (this.isUnknownDivergence) { return this.$options.i18n.unknown; } @@ -134,6 +184,73 @@ export default { return this.$options.i18n.upToDate; }, }, + watch: { + hasConflicts(newVal) { + if (newVal && this.isSyncTriggered) { + this.showConflictsModal(); + this.isSyncTriggered = false; + } + }, + }, + methods: { + async syncForkWithPolling() { + await this.$apollo.mutate({ + mutation: syncForkMutation, + variables: { + projectPath: this.projectPath, + targetBranch: this.selectedBranch, + }, + error(error) { + createAlert({ + message: error.message, + captureError: true, + error, + }); + }, + update: (store, { data: { projectSyncFork } }) => { + const { details } = projectSyncFork; + + store.writeQuery({ + query: forkDetailsQuery, + variables: this.forkDetailsQueryVariables, + data: { + project: { + id: this.project.id, + forkDetails: details, + }, + }, + }); + }, + }); + }, + showConflictsModal() { + this.$refs.modal.show(); + }, + startSyncing() { + this.isSyncTriggered = true; + this.syncForkWithPolling(); + }, + checkIfSyncIsPossible() { + if (this.hasConflicts) { + this.showConflictsModal(); + } else { + this.startSyncing(); + } + }, + handlePolingInterval(loading) { + if (!loading && this.isSyncing) { + const backoff = POLLING_INTERVAL_BACKOFF; + const interval = this.currentPollInterval; + const newInterval = Math.min(interval * backoff, FIVE_MINUTES_IN_MS); + this.currentPollInterval = this.currentPollInterval + ? newInterval + : POLLING_INTERVAL_DEFAULT; + } + if (this.currentPollInterval === FIVE_MINUTES_IN_MS) { + this.$apollo.queries.forkDetailsQuery.stopPolling(); + } + }, + }, }; </script> @@ -141,23 +258,45 @@ export default { <div class="info-well gl-sm-display-flex gl-flex-direction-column"> <div class="well-segment gl-p-5 gl-w-full gl-display-flex"> <gl-icon name="fork" :size="16" class="gl-display-block gl-m-4 gl-text-center" /> - <div v-if="sourceName"> - {{ $options.i18n.forkedFrom }} - <gl-link data-qa-selector="forked_from_link" :href="sourcePath">{{ sourceName }}</gl-link> - <gl-skeleton-loader v-if="isLoading" :lines="1" /> - <div v-else class="gl-text-secondary" data-testid="divergence-message"> - <gl-sprintf :message="forkDivergenceMessage"> - <template #aheadLink="{ content }"> - <gl-link :href="aheadComparePath">{{ content }}</gl-link> - </template> - <template #behindLink="{ content }"> - <gl-link :href="behindComparePath">{{ content }}</gl-link> - </template> - </gl-sprintf> + <div + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-grow-1" + > + <div v-if="sourceName"> + {{ $options.i18n.forkedFrom }} + <gl-link data-qa-selector="forked_from_link" :href="sourcePath">{{ sourceName }}</gl-link> + <gl-skeleton-loader v-if="isLoading" :lines="1" /> + <div v-else class="gl-text-secondary" data-testid="divergence-message"> + <gl-sprintf :message="forkDivergenceMessage"> + <template #aheadLink="{ content }"> + <gl-link :href="aheadComparePath">{{ content }}</gl-link> + </template> + <template #behindLink="{ content }"> + <gl-link :href="behindComparePath">{{ content }}</gl-link> + </template> + </gl-sprintf> + </div> </div> - </div> - <div v-else data-testid="inaccessible-project" class="gl-align-items-center gl-display-flex"> - {{ $options.i18n.inaccessibleProject }} + <div + v-else + data-testid="inaccessible-project" + class="gl-align-items-center gl-display-flex" + > + {{ $options.i18n.inaccessibleProject }} + </div> + <gl-button + v-if="isSyncButtonAvailable" + :disabled="forkDetails.isSyncing" + @click="checkIfSyncIsPossible" + > + <gl-loading-icon v-if="forkDetails.isSyncing" class="gl-display-inline" size="sm" /> + <span>{{ $options.i18n.sync }}</span> + </gl-button> + <conflicts-modal + ref="modal" + :source-name="sourceName" + :source-path="sourcePath" + :source-default-branch="sourceDefaultBranch" + /> </div> </div> </div> diff --git a/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue b/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue new file mode 100644 index 00000000000..0bfb90bb3ec --- /dev/null +++ b/app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue @@ -0,0 +1,137 @@ +<script> +/* eslint-disable @gitlab/require-i18n-strings */ +import { GlModal, GlButton } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; +import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { getBaseURL } from '~/lib/utils/url_utility'; + +export const i18n = { + modalTitle: s__('ForksDivergence|Resolve merge conflicts manually'), + modalMessage: s__( + 'ForksDivergence|The upstream changes could not be synchronized to this project due to file conflicts in the default branch. You must resolve the conflicts manually:', + ), + step1: __('Step 1.'), + step2: __('Step 2.'), + step3: __('Step 3.'), + step4: __('Step 4.'), + step1Text: s__( + "ForksDivergence|Fetch the latest changes from the upstream repository's default branch:", + ), + step2Text: s__( + "ForksDivergence|Check out to a new branch, and merge the changes from the upstream project's default branch. You likely need to resolve conflicts during this step.", + ), + step3Text: s__('ForksDivergence|Push the updates to remote:'), + step4Text: s__("ForksDivergence|Create a merge request to your project's default branch."), + copyToClipboard: __('Copy to clipboard'), + close: __('Close'), +}; + +export default { + name: 'ForkSyncConflictsModal', + components: { + GlModal, + GlButton, + ModalCopyButton, + }, + directives: { + SafeHtml, + }, + props: { + sourceDefaultBranch: { + type: String, + required: false, + default: '', + }, + sourceName: { + type: String, + required: false, + default: '', + }, + sourcePath: { + type: String, + required: false, + default: '', + }, + }, + computed: { + instructionsStep1() { + const baseUrl = getBaseURL(); + return `git fetch ${baseUrl}${this.sourcePath} ${this.sourceDefaultBranch}`; + }, + }, + methods: { + show() { + this.$refs.modal.show(); + }, + hide() { + this.$refs.modal.hide(); + }, + }, + i18n, + instructionsStep2: 'git checkout -b <new-branch-name>\ngit merge FETCH_HEAD', + instructionsStep2Clipboard: 'git checkout -b <new-branch-name>\ngit merge FETCH_HEAD', + instructionsStep3: 'git commit\ngit push', +}; +</script> +<template> + <gl-modal + ref="modal" + modal-id="fork-sync-conflicts-modal" + :title="$options.i18n.modalTitle" + size="md" + > + <p>{{ $options.i18n.modalMessage }}</p> + <p> + <b> {{ $options.i18n.step1 }}</b> {{ $options.i18n.modalMessage }} + </p> + <div class="gl-display-flex gl-mb-4"> + <pre class="gl-w-full gl-mb-0 gl-mr-3" data-testid="resolve-conflict-instructions">{{ + instructionsStep1 + }}</pre> + <modal-copy-button + modal-id="fork-sync-conflicts-modal" + :text="instructionsStep1" + :title="$options.i18n.copyToClipboard" + class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0" + /> + </div> + <p> + <b> {{ $options.i18n.step2 }}</b> {{ $options.i18n.step2Text }} + </p> + <div class="gl-display-flex gl-mb-4"> + <pre + class="gl-w-full gl-mb-0 gl-mr-3" + data-testid="resolve-conflict-instructions" + v-html="$options.instructionsStep2 /* eslint-disable-line vue/no-v-html */" + ></pre> + <modal-copy-button + modal-id="fork-sync-conflicts-modal" + :text="$options.instructionsStep2Clipboard" + :title="$options.i18n.copyToClipboard" + class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0" + /> + </div> + <p> + <b> {{ $options.i18n.step3 }}</b> {{ $options.i18n.step3Text }} + </p> + <div class="gl-display-flex gl-mb-4"> + <pre class="gl-w-full gl-mb-0" data-testid="resolve-conflict-instructions" + >{{ $options.instructionsStep3 }} +</pre + > + <modal-copy-button + modal-id="fork-sync-conflicts-modal" + :text="$options.instructionsStep3" + :title="$options.i18n.copyToClipboard" + class="gl-shadow-none! gl-bg-transparent! gl-flex-shrink-0 gl-ml-3" + /> + </div> + <p> + <b> {{ $options.i18n.step4 }}</b> {{ $options.i18n.step4Text }} + </p> + <template #modal-footer> + <gl-button @click="hide" @keydown.esc="hide">{{ $options.i18n.close }}</gl-button> + </template> + </gl-modal> +</template> diff --git a/app/assets/javascripts/repository/constants.js b/app/assets/javascripts/repository/constants.js index 73fecee92b7..a6191203b2f 100644 --- a/app/assets/javascripts/repository/constants.js +++ b/app/assets/javascripts/repository/constants.js @@ -105,3 +105,10 @@ export const i18n = { generalError: __('An error occurred while fetching folder content.'), gitalyError: __('Error: Gitaly is unavailable. Contact your administrator.'), }; + +export const FIVE_MINUTES_IN_MS = 1000 * 60 * 5; + +export const POLLING_INTERVAL_DEFAULT = 2500; +export const POLLING_INTERVAL_BACKOFF = 2; + +export const CONFLICTS_MODAL_ID = 'fork-sync-conflicts-modal'; diff --git a/app/assets/javascripts/repository/index.js b/app/assets/javascripts/repository/index.js index 494e270a66c..6cedc606a37 100644 --- a/app/assets/javascripts/repository/index.js +++ b/app/assets/javascripts/repository/index.js @@ -69,7 +69,13 @@ export default function setupVueRepositoryList() { if (!forkEl) { return null; } - const { sourceName, sourcePath, aheadComparePath, behindComparePath } = forkEl.dataset; + const { + sourceName, + sourcePath, + sourceDefaultBranch, + aheadComparePath, + behindComparePath, + } = forkEl.dataset; return new Vue({ el: forkEl, apolloProvider, @@ -80,6 +86,7 @@ export default function setupVueRepositoryList() { selectedBranch: ref, sourceName, sourcePath, + sourceDefaultBranch, aheadComparePath, behindComparePath, }, diff --git a/app/assets/javascripts/repository/mutations/sync_fork.mutation.graphql b/app/assets/javascripts/repository/mutations/sync_fork.mutation.graphql new file mode 100644 index 00000000000..b3426038694 --- /dev/null +++ b/app/assets/javascripts/repository/mutations/sync_fork.mutation.graphql @@ -0,0 +1,11 @@ +mutation syncFork($projectPath: ID!, $targetBranch: String!) { + projectSyncFork(input: { projectPath: $projectPath, targetBranch: $targetBranch }) { + details { + ahead + behind + isSyncing + hasConflicts + } + errors + } +} diff --git a/app/assets/javascripts/repository/queries/fork_details.query.graphql b/app/assets/javascripts/repository/queries/fork_details.query.graphql index d1a37d00d55..3d37f69b48d 100644 --- a/app/assets/javascripts/repository/queries/fork_details.query.graphql +++ b/app/assets/javascripts/repository/queries/fork_details.query.graphql @@ -4,6 +4,8 @@ query getForkDetails($projectPath: ID!, $ref: String) { forkDetails(ref: $ref) { ahead behind + isSyncing + hasConflicts } } } diff --git a/app/assets/stylesheets/framework/filters.scss b/app/assets/stylesheets/framework/filters.scss index f10b1974a4b..16c0a67f137 100644 --- a/app/assets/stylesheets/framework/filters.scss +++ b/app/assets/stylesheets/framework/filters.scss @@ -227,7 +227,7 @@ min-width: 200px; padding-right: 25px; padding-left: 0; - height: $input-height - 2; + height: $input-height; line-height: inherit; &, diff --git a/app/assets/stylesheets/framework/forms.scss b/app/assets/stylesheets/framework/forms.scss index b6ac4939a9c..3e1dff18f2a 100644 --- a/app/assets/stylesheets/framework/forms.scss +++ b/app/assets/stylesheets/framework/forms.scss @@ -159,6 +159,7 @@ label { } .select-control { + line-height: 1.3; padding-left: 10px; padding-right: 10px; appearance: none; @@ -211,7 +212,7 @@ label { .gl-show-field-errors { .form-control:not(textarea) { - height: 34px; + height: $input-height; } .gl-field-success-outline { @@ -249,7 +250,7 @@ label { .show-password-complexity-errors { .form-control:not(textarea) { - height: 34px; + height: $input-height; } .password-complexity-error-outline { diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index dff0ca9a819..0bc2e0583bb 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -745,7 +745,7 @@ $logs-p-color: #333; /* * Forms */ -$input-height: 34px; +$input-height: 32px; $input-danger-bg: #f2dede; $input-group-addon-bg: $gray-10; $gl-field-focus-shadow: rgba(0, 0, 0, 0.075); diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss index d4270235b0d..b4e896325d6 100644 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ b/app/assets/stylesheets/startup/startup-dark.scss @@ -119,7 +119,7 @@ kbd kbd { .form-control { display: block; width: 100%; - height: 34px; + height: 32px; padding: 0.375rem 0.75rem; font-size: 0.875rem; font-weight: 400; diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss index 005b356aae7..0a0fa83ff67 100644 --- a/app/assets/stylesheets/startup/startup-general.scss +++ b/app/assets/stylesheets/startup/startup-general.scss @@ -119,7 +119,7 @@ kbd kbd { .form-control { display: block; width: 100%; - height: 34px; + height: 32px; padding: 0.375rem 0.75rem; font-size: 0.875rem; font-weight: 400; diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss index fc7bb19fcfe..57f61508178 100644 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ b/app/assets/stylesheets/startup/startup-signin.scss @@ -192,7 +192,7 @@ hr { .form-control { display: block; width: 100%; - height: 34px; + height: 32px; padding: 0.375rem 0.75rem; font-size: 0.875rem; font-weight: 400; @@ -721,7 +721,7 @@ label.label-bold { color: #89888d; } .gl-show-field-errors .form-control:not(textarea) { - height: 34px; + height: 32px; } .navbar-empty { justify-content: center; diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index 7f6da83611e..f18055f80b7 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -39,6 +39,7 @@ class ProjectsController < Projects::ApplicationController before_action do push_frontend_feature_flag(:highlight_js, @project) push_frontend_feature_flag(:file_line_blame, @project) + push_frontend_feature_flag(:synchronize_fork, @project) push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks) push_licensed_feature(:security_orchestration_policies) if @project.present? && @project.licensed_feature_available?(:security_orchestration_policies) push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?) diff --git a/app/graphql/types/branch_protections/base_access_level_type.rb b/app/graphql/types/branch_protections/base_access_level_type.rb index e6514ba8d7d..472733a6bc5 100644 --- a/app/graphql/types/branch_protections/base_access_level_type.rb +++ b/app/graphql/types/branch_protections/base_access_level_type.rb @@ -14,7 +14,7 @@ module Types type: GraphQL::Types::String, null: false, description: 'Human readable representation for this access level.', - method: 'humanize' + hash_key: 'humanize' end end end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 33262957d16..a854b9990d2 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -133,6 +133,7 @@ module ProjectsHelper { source_name: source_project.full_name, source_path: project_path(source_project), + source_default_branch: source_default_branch, ahead_compare_path: project_compare_path( project, from: source_default_branch, to: ref, from_project_id: source_project.id ), diff --git a/app/models/projects/import_export/relation_export.rb b/app/models/projects/import_export/relation_export.rb index 9bdf10d7c0e..2771c5131b2 100644 --- a/app/models/projects/import_export/relation_export.rb +++ b/app/models/projects/import_export/relation_export.rb @@ -51,12 +51,16 @@ module Projects transition queued: :started end + event :retry do + transition started: :queued + end + event :finish do transition started: :finished end event :fail_op do - transition [:queued, :started] => :failed + transition [:queued, :started, :failed] => :failed end end @@ -65,6 +69,14 @@ module Projects project_tree_relation_names + EXTRA_RELATION_LIST end + + def mark_as_failed(export_error) + sanitized_error = Gitlab::UrlSanitizer.sanitize(export_error) + + fail_op + + update_column(:export_error, sanitized_error) + end end end end diff --git a/app/services/projects/import_export/relation_export_service.rb b/app/services/projects/import_export/relation_export_service.rb index dce40cf18ba..33da5b39c20 100644 --- a/app/services/projects/import_export/relation_export_service.rb +++ b/app/services/projects/import_export/relation_export_service.rb @@ -85,6 +85,7 @@ module Projects logger.error( message: 'Project relation export failed', export_error: error_message, + relation: relation_export.relation, project_export_job_id: project_export_job.id, project_name: project.name, project_id: project.id diff --git a/app/views/projects/protected_tags/_create_protected_tag.html.haml b/app/views/projects/protected_tags/_create_protected_tag.html.haml index d19a6401fc8..ef3974b04b5 100644 --- a/app/views/projects/protected_tags/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/_create_protected_tag.html.haml @@ -1,9 +1,9 @@ - content_for :create_access_levels do .create_access_levels-container = dropdown_tag('Select', - options: { toggle_class: 'js-allowed-to-create wide', + options: { toggle_class: 'js-allowed-to-create js-multiselect wide', dropdown_class: 'dropdown-menu-selectable capitalize-header', - dropdown_qa_selector: 'access_levels_content', + dropdown_qa_selector: 'access_levels_content', dropdown_testid: 'allowed-to-create-dropdown', data: { field_name: 'protected_tag[create_access_levels_attributes][0][access_level]', input_id: 'create_access_levels_attributes', qa_selector: 'access_levels_dropdown' }}) = render 'projects/protected_tags/shared/create_protected_tag' diff --git a/app/views/projects/protected_tags/_protected_tag.html.haml b/app/views/projects/protected_tags/_protected_tag.html.haml index e0912bf39c0..68e4a5e97a3 100644 --- a/app/views/projects/protected_tags/_protected_tag.html.haml +++ b/app/views/projects/protected_tags/_protected_tag.html.haml @@ -1,4 +1,4 @@ = render layout: 'projects/protected_tags/shared/protected_tag', locals: { protected_tag: protected_tag } do - %td - = render 'projects/protected_tags/protected_tag_create_access_levels', protected_tag: protected_tag, create_access_level: protected_tag.create_access_levels.for_role.first + %td.create_access_levels-container + = render 'projects/protected_tags/protected_tag_create_access_levels', protected_tag: protected_tag, create_access_level: protected_tag.create_access_levels.for_role = render_if_exists 'projects/protected_tags/protected_tag_extra_create_access_levels', protected_tag: protected_tag diff --git a/app/views/projects/protected_tags/_protected_tag_create_access_levels.haml b/app/views/projects/protected_tags/_protected_tag_create_access_levels.haml index 1d4e9565156..30b9e3e9005 100644 --- a/app/views/projects/protected_tags/_protected_tag_create_access_levels.haml +++ b/app/views/projects/protected_tags/_protected_tag_create_access_levels.haml @@ -1,8 +1,8 @@ - protected_tag = local_assigns.fetch(:protected_tag) - create_access_level = local_assigns.fetch(:create_access_level) -- dropdown_label = create_access_level&.humanize || 'Select' +- dropdown_label = create_access_level.first&.humanize || 'Select' -= hidden_field_tag "allowed_to_create_#{protected_tag.id}", create_access_level&.access_level += hidden_field_tag "allowed_to_create_#{protected_tag.id}", create_access_level.first&.access_level = dropdown_tag(dropdown_label, - options: { toggle_class: 'js-allowed-to-create', dropdown_class: 'dropdown-menu-selectable capitalize-header js-allowed-to-create-container', - data: { field_name: "allowed_to_create_#{protected_tag.id}", access_level_id: create_access_level&.id }}) + options: { toggle_class: 'js-allowed-to-create js-multiselect', dropdown_class: 'dropdown-menu-selectable capitalize-header js-allowed-to-create-container', + data: { field_name: "allowed_to_create_#{protected_tag.id}", preselected_items: access_levels_data(create_access_level) }}) diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index ef4c31c696e..1624538152e 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -3144,6 +3144,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: projects_import_export_create_relation_exports + :worker_name: Projects::ImportExport::CreateRelationExportsWorker + :feature_category: :importers + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :cpu + :weight: 1 + :idempotent: true + :tags: [] - :name: projects_import_export_parallel_project_export :worker_name: Projects::ImportExport::ParallelProjectExportWorker :feature_category: :importers @@ -3162,6 +3171,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: projects_import_export_wait_relation_exports + :worker_name: Projects::ImportExport::WaitRelationExportsWorker + :feature_category: :importers + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :cpu + :weight: 1 + :idempotent: true + :tags: [] - :name: projects_inactive_projects_deletion_notification :worker_name: Projects::InactiveProjectsDeletionNotificationWorker :feature_category: :compliance_management diff --git a/app/workers/projects/import_export/create_relation_exports_worker.rb b/app/workers/projects/import_export/create_relation_exports_worker.rb new file mode 100644 index 00000000000..9ca69a5500a --- /dev/null +++ b/app/workers/projects/import_export/create_relation_exports_worker.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Projects + module ImportExport + class CreateRelationExportsWorker + include ApplicationWorker + include ExceptionBacktrace + + idempotent! + data_consistency :always + deduplicate :until_executed + feature_category :importers + worker_resource_boundary :cpu + sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION + + # This delay is an arbitrary number to finish the export quicker in case all relations + # are exported before the first execution of the WaitRelationExportsWorker worker. + INITIAL_DELAY = 10.seconds + + # rubocop: disable CodeReuse/ActiveRecord + def perform(user_id, project_id, after_export_strategy = {}) + project = Project.find_by_id(project_id) + return unless project + + project_export_job = project.export_jobs.find_or_create_by!(jid: jid) + return if project_export_job.started? + + relation_exports = RelationExport.relation_names_list.map do |relation_name| + project_export_job.relation_exports.find_or_create_by!(relation: relation_name) + end + + relation_exports.each do |relation_export| + RelationExportWorker.with_status.perform_async(relation_export.id) + end + + WaitRelationExportsWorker.perform_in( + INITIAL_DELAY, + project_export_job.id, + user_id, + after_export_strategy + ) + + project_export_job.start! + end + # rubocop: enable CodeReuse/ActiveRecord + end + end +end diff --git a/app/workers/projects/import_export/relation_export_worker.rb b/app/workers/projects/import_export/relation_export_worker.rb index 13ca33c4457..7747d4f4099 100644 --- a/app/workers/projects/import_export/relation_export_worker.rb +++ b/app/workers/projects/import_export/relation_export_worker.rb @@ -10,13 +10,34 @@ module Projects data_consistency :always deduplicate :until_executed feature_category :importers - sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION + sidekiq_options dead: false, status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION urgency :low worker_resource_boundary :memory + sidekiq_retries_exhausted do |job, exception| + relation_export = Projects::ImportExport::RelationExport.find(job['args'].first) + project_export_job = relation_export.project_export_job + project = project_export_job.project + + relation_export.mark_as_failed(job['error_message']) + + log_payload = { + message: 'Project relation export failed', + export_error: job['error_message'], + relation: relation_export.relation, + project_export_job_id: project_export_job.id, + project_name: project.name, + project_id: project.id + } + Gitlab::ExceptionLogFormatter.format!(exception, log_payload) + Gitlab::Export::Logger.error(log_payload) + end + def perform(project_relation_export_id) relation_export = Projects::ImportExport::RelationExport.find(project_relation_export_id) + relation_export.retry! if relation_export.started? + if relation_export.queued? Projects::ImportExport::RelationExportService.new(relation_export, jid).execute end diff --git a/app/workers/projects/import_export/wait_relation_exports_worker.rb b/app/workers/projects/import_export/wait_relation_exports_worker.rb new file mode 100644 index 00000000000..4250073edce --- /dev/null +++ b/app/workers/projects/import_export/wait_relation_exports_worker.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Projects + module ImportExport + class WaitRelationExportsWorker + include ApplicationWorker + include ExceptionBacktrace + + idempotent! + data_consistency :always + deduplicate :until_executed + feature_category :importers + loggable_arguments 1, 2 + worker_resource_boundary :cpu + sidekiq_options dead: false, status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION + + INTERVAL = 1.minute + + def perform(project_export_job_id, user_id, after_export_strategy = {}) + @export_job = ProjectExportJob.find(project_export_job_id) + + return unless @export_job.started? + + @export_job.update_attribute(:jid, jid) + @relation_exports = @export_job.relation_exports + + if queued_relation_exports.any? || started_relation_exports.any? + fail_started_jobs_no_longer_running + + self.class.perform_in(INTERVAL, project_export_job_id, user_id, after_export_strategy) + return + end + + if all_relation_export_finished? + ParallelProjectExportWorker.perform_async(project_export_job_id, user_id, after_export_strategy) + return + end + + fail_and_notify_user(user_id) + end + + private + + def relation_exports_with_status(status) + @relation_exports.select { |relation_export| relation_export.status == status } + end + + def queued_relation_exports + relation_exports_with_status(RelationExport::STATUS[:queued]) + end + + def started_relation_exports + @started_relation_exports ||= relation_exports_with_status(RelationExport::STATUS[:started]) + end + + def all_relation_export_finished? + @relation_exports.all? { |relation_export| relation_export.status == RelationExport::STATUS[:finished] } + end + + def fail_started_jobs_no_longer_running + started_relation_exports.each do |relation_export| + next if Gitlab::SidekiqStatus.running?(relation_export.jid) + next if relation_export.reset.finished? + + relation_export.mark_as_failed("Exausted number of retries to export: #{relation_export.relation}") + end + end + + def fail_and_notify_user(user_id) + @export_job.fail_op! + + @user = User.find_by_id(user_id) + return unless @user + + failed_relation_exports = relation_exports_with_status(RelationExport::STATUS[:failed]) + errors = failed_relation_exports.map(&:export_error) + + NotificationService.new.project_not_exported(@export_job.project, @user, errors) + end + end + end +end |