summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/grafana_integration/store/actions.js2
-rw-r--r--app/assets/javascripts/operation_settings/store/actions.js2
-rw-r--r--app/assets/javascripts/pages/projects/settings/repository/form.js4
-rw-r--r--app/assets/javascripts/projects/settings/access_dropdown.js13
-rw-r--r--app/assets/javascripts/projects/settings/components/access_dropdown.vue5
-rw-r--r--app/assets/javascripts/projects/settings/constants.js1
-rw-r--r--app/assets/javascripts/protected_tags/constants.js11
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_create.js81
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit.js110
-rw-r--r--app/assets/javascripts/protected_tags/protected_tag_edit_list.js4
-rw-r--r--app/assets/javascripts/repository/components/fork_info.vue195
-rw-r--r--app/assets/javascripts/repository/components/fork_sync_conflicts_modal.vue137
-rw-r--r--app/assets/javascripts/repository/constants.js7
-rw-r--r--app/assets/javascripts/repository/index.js9
-rw-r--r--app/assets/javascripts/repository/mutations/sync_fork.mutation.graphql11
-rw-r--r--app/assets/javascripts/repository/queries/fork_details.query.graphql2
-rw-r--r--app/assets/stylesheets/framework/filters.scss2
-rw-r--r--app/assets/stylesheets/framework/forms.scss5
-rw-r--r--app/assets/stylesheets/framework/variables.scss2
-rw-r--r--app/assets/stylesheets/startup/startup-dark.scss2
-rw-r--r--app/assets/stylesheets/startup/startup-general.scss2
-rw-r--r--app/assets/stylesheets/startup/startup-signin.scss4
-rw-r--r--app/controllers/projects_controller.rb1
-rw-r--r--app/graphql/types/branch_protections/base_access_level_type.rb2
-rw-r--r--app/helpers/projects_helper.rb1
-rw-r--r--app/models/projects/import_export/relation_export.rb14
-rw-r--r--app/services/projects/import_export/relation_export_service.rb1
-rw-r--r--app/views/projects/protected_tags/_create_protected_tag.html.haml4
-rw-r--r--app/views/projects/protected_tags/_protected_tag.html.haml4
-rw-r--r--app/views/projects/protected_tags/_protected_tag_create_access_levels.haml8
-rw-r--r--app/workers/all_queues.yml18
-rw-r--r--app/workers/projects/import_export/create_relation_exports_worker.rb48
-rw-r--r--app/workers/projects/import_export/relation_export_worker.rb23
-rw-r--r--app/workers/projects/import_export/wait_relation_exports_worker.rb82
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 &lt;new-branch-name&gt;\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