Commit 4955e03e authored by Illya Klymov's avatar Illya Klymov

Implement importing one group for group imports MVC

- POST to relevant endpoint
- polling of groups statuses to check when import is completed
parent bd84e89b
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import createDefaultClient from '~/lib/graphql'; import createDefaultClient from '~/lib/graphql';
import { s__ } from '~/locale';
import createFlash from '~/flash';
import { STATUSES } from '../../constants'; import { STATUSES } from '../../constants';
import availableNamespacesQuery from './queries/available_namespaces.query.graphql'; import availableNamespacesQuery from './queries/available_namespaces.query.graphql';
import { SourceGroupsManager } from './services/source_groups_manager'; import { SourceGroupsManager } from './services/source_groups_manager';
import { StatusPoller } from './services/status_poller';
export const clientTypenames = { export const clientTypenames = {
BulkImportSourceGroup: 'ClientBulkImportSourceGroup', BulkImportSourceGroup: 'ClientBulkImportSourceGroup',
...@@ -10,6 +13,8 @@ export const clientTypenames = { ...@@ -10,6 +13,8 @@ export const clientTypenames = {
}; };
export function createResolvers({ endpoints }) { export function createResolvers({ endpoints }) {
let statusPoller;
return { return {
Query: { Query: {
async bulkImportSourceGroups(_, __, { client }) { async bulkImportSourceGroups(_, __, { client }) {
...@@ -57,6 +62,30 @@ export function createResolvers({ endpoints }) { ...@@ -57,6 +62,30 @@ export function createResolvers({ endpoints }) {
const groupManager = new SourceGroupsManager({ client }); const groupManager = new SourceGroupsManager({ client });
const group = groupManager.findById(sourceGroupId); const group = groupManager.findById(sourceGroupId);
groupManager.setImportStatus(group, STATUSES.SCHEDULING); groupManager.setImportStatus(group, STATUSES.SCHEDULING);
try {
await axios.post(endpoints.createBulkImport, {
bulk_import: [
{
source_type: 'group_entity',
source_full_path: group.full_path,
destination_namespace: group.import_target.target_namespace,
destination_name: group.import_target.new_name,
},
],
});
groupManager.setImportStatus(group, STATUSES.STARTED);
if (!statusPoller) {
statusPoller = new StatusPoller({ client, interval: 3000 });
statusPoller.startPolling();
}
} catch (e) {
createFlash({
message: s__('BulkImport|Importing the group failed'),
});
groupManager.setImportStatus(group, STATUSES.NONE);
throw e;
}
}, },
}, },
}; };
......
import gql from 'graphql-tag';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import bulkImportSourceGroupsQuery from '../queries/bulk_import_source_groups.query.graphql';
import { STATUSES } from '../../../constants';
import { SourceGroupsManager } from './source_groups_manager';
const groupId = i => `group${i}`;
function generateGroupsQuery(groups) {
return gql`{
${groups
.map(
(g, idx) =>
`${groupId(idx)}: group(fullPath: "${g.import_target.target_namespace}/${
g.import_target.new_name
}") { id }`,
)
.join('\n')}
}`;
}
export class StatusPoller {
constructor({ client, interval }) {
this.client = client;
this.interval = interval;
this.timeoutId = null;
this.groupManager = new SourceGroupsManager({ client });
}
startPolling() {
if (this.timeoutId) {
return;
}
this.checkPendingImports();
}
stopPolling() {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
async checkPendingImports() {
try {
const { bulkImportSourceGroups } = this.client.readQuery({
query: bulkImportSourceGroupsQuery,
});
const groupsInProgress = bulkImportSourceGroups.filter(g => g.status === STATUSES.STARTED);
if (groupsInProgress.length) {
const { data: results } = await this.client.query({
query: generateGroupsQuery(groupsInProgress),
fetchPolicy: 'no-cache',
});
const completedGroups = groupsInProgress.filter((_, idx) => Boolean(results[groupId(idx)]));
completedGroups.forEach(group => {
this.groupManager.setImportStatus(group, STATUSES.FINISHED);
});
}
} catch (e) {
createFlash({
message: s__('BulkImport|Update of import statuses with realtime changes failed'),
});
} finally {
this.timeoutId = setTimeout(() => this.checkPendingImports(), this.interval);
}
}
}
...@@ -3,9 +3,9 @@ ...@@ -3,9 +3,9 @@
- breadcrumb_title _('Import groups') - breadcrumb_title _('Import groups')
%h1.gl-my-0.gl-py-4.gl-font-size-h1.gl-border-solid.gl-border-gray-200.gl-border-0.gl-border-b-1 %h1.gl-my-0.gl-py-4.gl-font-size-h1.gl-border-solid.gl-border-gray-200.gl-border-0.gl-border-b-1
= s_('ImportGroups|Import groups from GitLab') = s_('BulkImport|Import groups from GitLab')
%p.gl-my-0.gl-py-5.gl-border-solid.gl-border-gray-200.gl-border-0.gl-border-b-1 %p.gl-my-0.gl-py-5.gl-border-solid.gl-border-gray-200.gl-border-0.gl-border-b-1
= s_('ImportGroups|Importing groups from %{link}').html_safe % { link: external_link(@source_url, @source_url) } = s_('BulkImport|Importing groups from %{link}').html_safe % { link: external_link(@source_url, @source_url) }
#import-groups-mount-element{ data: { status_path: status_import_bulk_imports_path(format: :json), #import-groups-mount-element{ data: { status_path: status_import_bulk_imports_path(format: :json),
available_namespaces_path: import_available_namespaces_path(format: :json), available_namespaces_path: import_available_namespaces_path(format: :json),
......
---
title: Introduce frontend for group migration MVC
merge_request: 49709
author:
type: added
...@@ -4777,9 +4777,21 @@ msgstr "" ...@@ -4777,9 +4777,21 @@ msgstr ""
msgid "BulkImport|From source group" msgid "BulkImport|From source group"
msgstr "" msgstr ""
msgid "BulkImport|Import groups from GitLab"
msgstr ""
msgid "BulkImport|Importing groups from %{link}"
msgstr ""
msgid "BulkImport|Importing the group failed"
msgstr ""
msgid "BulkImport|To new group" msgid "BulkImport|To new group"
msgstr "" msgstr ""
msgid "BulkImport|Update of import statuses with realtime changes failed"
msgstr ""
msgid "BulkImport|expected an associated Group but has an associated Project" msgid "BulkImport|expected an associated Group but has an associated Project"
msgstr "" msgstr ""
...@@ -14476,12 +14488,6 @@ msgstr "" ...@@ -14476,12 +14488,6 @@ msgstr ""
msgid "ImportButtons|Connect repositories from" msgid "ImportButtons|Connect repositories from"
msgstr "" msgstr ""
msgid "ImportGroups|Import groups from GitLab"
msgstr ""
msgid "ImportGroups|Importing groups from %{link}"
msgstr ""
msgid "ImportProjects|%{provider} rate limit exceeded. Try again later" msgid "ImportProjects|%{provider} rate limit exceeded. Try again later"
msgstr "" msgstr ""
......
...@@ -7,6 +7,7 @@ import { ...@@ -7,6 +7,7 @@ import {
clientTypenames, clientTypenames,
createResolvers, createResolvers,
} from '~/import_entities/import_groups/graphql/client_factory'; } from '~/import_entities/import_groups/graphql/client_factory';
import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller';
import { STATUSES } from '~/import_entities/constants'; import { STATUSES } from '~/import_entities/constants';
import bulkImportSourceGroupsQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql'; import bulkImportSourceGroupsQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql';
...@@ -17,6 +18,12 @@ import importGroupMutation from '~/import_entities/import_groups/graphql/mutatio ...@@ -17,6 +18,12 @@ import importGroupMutation from '~/import_entities/import_groups/graphql/mutatio
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
import { statusEndpointFixture, availableNamespacesFixture } from './fixtures'; import { statusEndpointFixture, availableNamespacesFixture } from './fixtures';
jest.mock('~/import_entities/import_groups/graphql/services/status_poller', () => ({
StatusPoller: jest.fn().mockImplementation(function mock() {
this.startPolling = jest.fn();
}),
}));
const FAKE_ENDPOINTS = { const FAKE_ENDPOINTS = {
status: '/fake_status_url', status: '/fake_status_url',
availableNamespaces: '/fake_available_namespaces', availableNamespaces: '/fake_available_namespaces',
...@@ -173,6 +180,42 @@ describe('Bulk import resolvers', () => { ...@@ -173,6 +180,42 @@ describe('Bulk import resolvers', () => {
expect(intermediateResults[0].status).toBe(STATUSES.SCHEDULING); expect(intermediateResults[0].status).toBe(STATUSES.SCHEDULING);
}); });
it('sets group status to STARTED when request completes', async () => {
axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK);
await client.mutate({
mutation: importGroupMutation,
variables: { sourceGroupId: GROUP_ID },
});
expect(results[0].status).toBe(STATUSES.STARTED);
});
it('starts polling when request completes', async () => {
axiosMockAdapter.onPost(FAKE_ENDPOINTS.createBulkImport).reply(httpStatus.OK);
await client.mutate({
mutation: importGroupMutation,
variables: { sourceGroupId: GROUP_ID },
});
const [statusPoller] = StatusPoller.mock.instances;
expect(statusPoller.startPolling).toHaveBeenCalled();
});
it('resets status to NONE if request fails', async () => {
axiosMockAdapter
.onPost(FAKE_ENDPOINTS.createBulkImport)
.reply(httpStatus.INTERNAL_SERVER_ERROR);
client
.mutate({
mutation: importGroupMutation,
variables: { sourceGroupId: GROUP_ID },
})
.catch(() => {});
await waitForPromises();
expect(results[0].status).toBe(STATUSES.NONE);
});
}); });
}); });
}); });
import { createMockClient } from 'mock-apollo-client';
import { InMemoryCache } from 'apollo-cache-inmemory';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import { StatusPoller } from '~/import_entities/import_groups/graphql/services/status_poller';
import bulkImportSourceGroupsQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql';
import { STATUSES } from '~/import_entities/constants';
import { SourceGroupsManager } from '~/import_entities/import_groups/graphql/services/source_groups_manager';
import { generateFakeEntry } from '../fixtures';
jest.mock('~/flash');
jest.mock('~/import_entities/import_groups/graphql/services/source_groups_manager', () => ({
SourceGroupsManager: jest.fn().mockImplementation(function mock() {
this.setImportStatus = jest.fn();
}),
}));
const TEST_POLL_INTERVAL = 1000;
describe('Bulk import status poller', () => {
let poller;
let clientMock;
const listQueryCacheCalls = () =>
clientMock.readQuery.mock.calls.filter(call => call[0].query === bulkImportSourceGroupsQuery);
beforeEach(() => {
clientMock = createMockClient({
cache: new InMemoryCache({
fragmentMatcher: { match: () => true },
}),
});
jest.spyOn(clientMock, 'readQuery');
poller = new StatusPoller({
client: clientMock,
interval: TEST_POLL_INTERVAL,
});
});
describe('general behavior', () => {
beforeEach(() => {
clientMock.cache.writeQuery({
query: bulkImportSourceGroupsQuery,
data: { bulkImportSourceGroups: [] },
});
});
it('does not perform polling when constructed', () => {
jest.runOnlyPendingTimers();
expect(listQueryCacheCalls()).toHaveLength(0);
});
it('immediately start polling when requested', async () => {
await poller.startPolling();
expect(listQueryCacheCalls()).toHaveLength(1);
});
it('constantly polls when started', async () => {
poller.startPolling();
expect(listQueryCacheCalls()).toHaveLength(1);
jest.advanceTimersByTime(TEST_POLL_INTERVAL);
expect(listQueryCacheCalls()).toHaveLength(2);
jest.advanceTimersByTime(TEST_POLL_INTERVAL);
expect(listQueryCacheCalls()).toHaveLength(3);
});
it('does not start polling when requested multiple times', async () => {
poller.startPolling();
expect(listQueryCacheCalls()).toHaveLength(1);
poller.startPolling();
expect(listQueryCacheCalls()).toHaveLength(1);
});
it('stops polling when requested', async () => {
poller.startPolling();
expect(listQueryCacheCalls()).toHaveLength(1);
poller.stopPolling();
jest.runOnlyPendingTimers();
expect(listQueryCacheCalls()).toHaveLength(1);
});
it('does not query server when list is empty', async () => {
jest.spyOn(clientMock, 'query');
poller.startPolling();
expect(clientMock.query).not.toHaveBeenCalled();
});
});
it('does not query server when no groups have STARTED status', async () => {
clientMock.cache.writeQuery({
query: bulkImportSourceGroupsQuery,
data: {
bulkImportSourceGroups: [STATUSES.NONE, STATUSES.FINISHED].map((status, idx) =>
generateFakeEntry({ status, id: idx }),
),
},
});
jest.spyOn(clientMock, 'query');
poller.startPolling();
expect(clientMock.query).not.toHaveBeenCalled();
});
describe('when there are groups which have STARTED status', () => {
const TARGET_NAMESPACE = 'root';
const STARTED_GROUP_1 = {
status: STATUSES.STARTED,
id: 'started1',
import_target: {
target_namespace: TARGET_NAMESPACE,
new_name: 'group1',
},
};
const STARTED_GROUP_2 = {
status: STATUSES.STARTED,
id: 'started2',
import_target: {
target_namespace: TARGET_NAMESPACE,
new_name: 'group2',
},
};
const NOT_STARTED_GROUP = {
status: STATUSES.NONE,
id: 'not_started',
import_target: {
target_namespace: TARGET_NAMESPACE,
new_name: 'group3',
},
};
it('query server only for groups with STATUSES.STARTED', async () => {
clientMock.cache.writeQuery({
query: bulkImportSourceGroupsQuery,
data: {
bulkImportSourceGroups: [STARTED_GROUP_1, NOT_STARTED_GROUP, STARTED_GROUP_2].map(group =>
generateFakeEntry(group),
),
},
});
clientMock.query = jest.fn().mockResolvedValue({ data: {} });
poller.startPolling();
expect(clientMock.query).toHaveBeenCalledTimes(1);
await waitForPromises();
const [[doc]] = clientMock.query.mock.calls;
const { selections } = doc.query.definitions[0].selectionSet;
expect(selections.every(field => field.name.value === 'group')).toBeTruthy();
expect(selections.length).toBe(2);
expect(selections.map(sel => sel.arguments[0].value.value)).toStrictEqual([
`${TARGET_NAMESPACE}/${STARTED_GROUP_1.import_target.new_name}`,
`${TARGET_NAMESPACE}/${STARTED_GROUP_2.import_target.new_name}`,
]);
});
it('updates statuses only for groups in response', async () => {
clientMock.cache.writeQuery({
query: bulkImportSourceGroupsQuery,
data: {
bulkImportSourceGroups: [STARTED_GROUP_1, STARTED_GROUP_2].map(group =>
generateFakeEntry(group),
),
},
});
clientMock.query = jest.fn().mockResolvedValue({ data: { group0: {} } });
poller.startPolling();
await waitForPromises();
const [managerInstance] = SourceGroupsManager.mock.instances;
expect(managerInstance.setImportStatus).toHaveBeenCalledTimes(1);
expect(managerInstance.setImportStatus).toHaveBeenCalledWith(
expect.objectContaining({ id: STARTED_GROUP_1.id }),
STATUSES.FINISHED,
);
});
describe('when error occurs', () => {
beforeEach(() => {
clientMock.cache.writeQuery({
query: bulkImportSourceGroupsQuery,
data: {
bulkImportSourceGroups: [STARTED_GROUP_1, STARTED_GROUP_2].map(group =>
generateFakeEntry(group),
),
},
});
clientMock.query = jest.fn().mockRejectedValue(new Error('dummy error'));
poller.startPolling();
return waitForPromises();
});
it('reports an error', () => {
expect(createFlash).toHaveBeenCalled();
});
it('continues polling', async () => {
jest.advanceTimersByTime(TEST_POLL_INTERVAL);
expect(listQueryCacheCalls()).toHaveLength(2);
});
});
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment