diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2023-03-17 15:13:40 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2023-03-17 15:13:40 +0300 |
commit | db30b31f056d0de120d9238a7786e19cafbce69f (patch) | |
tree | 9fc08f552865dc4e28eb9db475014f86cc061495 /app/assets/javascripts/repository | |
parent | aab0be458344b772293da292cba11648011c4c1e (diff) |
Add latest changes from gitlab-org/gitlab@master
Diffstat (limited to 'app/assets/javascripts/repository')
6 files changed, 332 insertions, 29 deletions
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 } } } |