diff --git a/.changeset/purple-scissors-switch.md b/.changeset/purple-scissors-switch.md new file mode 100644 index 0000000000..fe3fd07f58 --- /dev/null +++ b/.changeset/purple-scissors-switch.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +--- + +Added action `gitlab:group:migrate` to migrate projects using `bulk_import` diff --git a/plugins/scaffolder-backend-module-gitlab/report.api.md b/plugins/scaffolder-backend-module-gitlab/report.api.md index 896c0a5899..2cdbce6366 100644 --- a/plugins/scaffolder-backend-module-gitlab/report.api.md +++ b/plugins/scaffolder-backend-module-gitlab/report.api.md @@ -88,6 +88,20 @@ export const createGitlabProjectDeployTokenAction: (options: { } >; +// @public +export const createGitlabProjectMigrateAction: (options: { + integrations: ScmIntegrationRegistry; +}) => TemplateAction< + { + destinationAccessToken: string; + destinationUrl: string; + sourceAccessToken: string; + sourceFullPath: string; + sourceUrl: string; + }, + JsonObject +>; + // @public export const createGitlabProjectVariableAction: (options: { integrations: ScmIntegrationRegistry; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectMigrate.examples.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectMigrate.examples.test.ts new file mode 100644 index 0000000000..869bda30ec --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectMigrate.examples.test.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ConfigReader } from '@backstage/config'; +import { ScmIntegrations } from '@backstage/integration'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +import { createGitlabProjectMigrateAction } from './gitlabProjectMigrate'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import { createMockDirectory } from '@backstage/backend-test-utils'; +import { examples } from './gitlabProjectMigrate.examples'; +import yaml from 'yaml'; + +const mockGitlabClient = { + Migrations: { + create: jest.fn(), + }, +}; + +jest.mock('@gitbeaker/rest', () => ({ + Gitlab: class { + constructor() { + return mockGitlabClient; + } + }, +})); + +describe('gitlab:group:migrate', () => { + let instance: TemplateAction; + + beforeEach(() => { + jest.clearAllMocks(); + + const config = new ConfigReader({ + integrations: { + gitlab: [ + { + host: 'gitlab.local.com', + token: 'token', + apiBaseUrl: 'https://api.gitlab.local.com', + }, + { + host: 'hosted.gitlab.com', + apiBaseUrl: 'https://api.hosted.gitlab.com', + }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + instance = createGitlabProjectMigrateAction({ integrations }); + }); + + describe('Migrate a gitlab project to a different gitlab instance in another', () => { + const workspacePath = createMockDirectory().resolve('workspace'); + + it(`Execute example ${examples[0].description}`, async () => { + const input = yaml.parse(examples[0].example).steps[0].input; + const ctx = createMockActionContext({ input, workspacePath }); + await instance.handler(ctx); + + expect(mockGitlabClient.Migrations.create).toHaveBeenCalledWith( + { + url: 'https://gitlab.remote.com', + access_token: 'lolstoken', + }, + [ + { + sourceType: 'project_entity', + sourceFullPath: 'my/wonderful-repo', + destinationSlug: 'precious-repo', + destinationNamespace: 'my/local/site', + }, + ], + ); + }); + }); +}); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectMigrate.examples.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectMigrate.examples.ts new file mode 100644 index 0000000000..9fe53cb415 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectMigrate.examples.ts @@ -0,0 +1,40 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { TemplateExample } from '@backstage/plugin-scaffolder-node'; +import yaml from 'yaml'; + +export const examples: TemplateExample[] = [ + { + description: 'Import repo from remote GitLab instance', + example: yaml.stringify({ + steps: [ + { + id: 'importGitLabRepo', + action: 'gitlab:repo:push', + name: 'Push changes to gitlab repository', + input: { + destinationAccessToken: 'tokenslols', + destinationUrl: + 'gitlab.local.com?repo=precious-repo&owner=my%2Flocal%2Fsite', + sourceAccessToken: 'lolstoken', + sourceFullPath: 'my/wonderful-repo', + sourceUrl: 'https://gitlab.remote.com', + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectMigrate.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectMigrate.test.ts new file mode 100644 index 0000000000..8e3bc6560d --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectMigrate.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { ConfigReader } from '@backstage/config'; +import { ScmIntegrations } from '@backstage/integration'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +import { createGitlabProjectMigrateAction } from './gitlabProjectMigrate'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import { createMockDirectory } from '@backstage/backend-test-utils'; + +const mockGitlabClient = { + Migrations: { + create: jest.fn(), + }, +}; + +jest.mock('@gitbeaker/rest', () => ({ + Gitlab: class { + constructor() { + return mockGitlabClient; + } + }, +})); + +describe('createGitlabRepoMigrate', () => { + let instance: TemplateAction; + + beforeEach(() => { + jest.clearAllMocks(); + + const config = new ConfigReader({ + integrations: { + gitlab: [ + { + host: 'gitlab.com', + token: 'token', + apiBaseUrl: 'https://api.gitlab.com', + }, + { + host: 'hosted.gitlab.com', + apiBaseUrl: 'https://api.hosted.gitlab.com', + }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + instance = createGitlabProjectMigrateAction({ integrations }); + }); + + describe('createGitlabRepoImportAction', () => { + const workspacePath = createMockDirectory().resolve('workspace'); + + it('default repo import action is created', async () => { + const input = { + destinationAccessToken: 'moreLOLsToken', + destinationUrl: + 'gitlab.com?owner=migrated%2Ffoo%2Fbar&repo=migrated-go-lang', + sourceUrl: 'https://gitlab.remote.com', + sourceAccessToken: 'lolstoken', + sourceFullPath: 'foo/bar/go-lang', + }; + const ctx = createMockActionContext({ input, workspacePath }); + await instance.handler(ctx); + + expect(mockGitlabClient.Migrations.create).toHaveBeenCalledWith( + { + url: 'https://gitlab.remote.com', + access_token: 'lolstoken', + }, + [ + { + sourceType: 'project_entity', + sourceFullPath: 'foo/bar/go-lang', + destinationSlug: 'migrated-go-lang', + destinationNamespace: 'migrated/foo/bar', + }, + ], + ); + }); + }); +}); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectMigrate.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectMigrate.ts new file mode 100644 index 0000000000..659215c1c0 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabProjectMigrate.ts @@ -0,0 +1,151 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { + createTemplateAction, + parseRepoUrl, +} from '@backstage/plugin-scaffolder-node'; +import { ScmIntegrationRegistry } from '@backstage/integration'; +import { InputError } from '@backstage/errors'; +import { createGitlabApi } from './helpers'; +import { examples } from './gitlabRepoPush.examples'; +import { MigrationEntityOptions } from '@gitbeaker/rest'; + +/** + * Create a new action that imports a gitlab project into another gitlab project (potentially from another gitlab instance). + * + * @public + */ +export const createGitlabProjectMigrateAction = (options: { + integrations: ScmIntegrationRegistry; +}) => { + const { integrations } = options; + + return createTemplateAction<{ + destinationAccessToken: string; + destinationUrl: string; + sourceAccessToken: string; + sourceFullPath: string; + sourceUrl: string; + }>({ + id: 'gitlab:group:migrate', + examples, + schema: { + input: { + required: [ + 'destinationAccessToken', + 'destinationUrl', + 'sourceAccessToken', + 'sourceFullPath', + 'sourceUrl', + ], + type: 'object', + properties: { + destinationAccessToken: { + type: 'string', + title: 'Target Repository Access Token', + description: `The token to use for authorization to the target GitLab'`, + }, + destinationUrl: { + type: 'string', + title: 'Target Project Location', + description: `Accepts the format 'gitlab.com?repo=project_name&owner=group_name' where 'project_name' is the repository name and 'group_name' is a group or username`, + }, + sourceAccessToken: { + type: 'string', + title: 'Source Group Access Token', + description: `The token to use for authorization to the source GitLab'`, + }, + sourceFullPath: { + type: 'string', + title: 'Group Full Path', + description: + 'Full path to the project in the source Gitlab instance', + }, + sourceUrl: { + type: 'string', + title: 'Source URL Location', + description: `Accepts the format 'https://gitlab.com/'`, + }, + }, + }, + output: { + type: 'object', + properties: { + importedRepoUrl: { + title: 'URL to the newly imported repo', + type: 'string', + }, + }, + }, + }, + + async handler(ctx) { + const { + destinationAccessToken, + destinationUrl, + sourceAccessToken, + sourceFullPath, + sourceUrl, + } = ctx.input; + + const { + host: destinationHost, + repo: destinationSlug, + owner: destinationNamespace, + } = parseRepoUrl(destinationUrl, integrations); + + if (!destinationNamespace) { + throw new InputError( + `Failed to determine target repository to migrate to. Make sure destinationUrl matches the format 'gitlab.myorg.com?repo=project_name&owner=group_name'`, + ); + } + + const api = createGitlabApi({ + integrations, + token: destinationAccessToken, + repoUrl: destinationUrl, + }); + + const migrationEntity: MigrationEntityOptions[] = [ + { + sourceType: 'project_entity', + sourceFullPath: sourceFullPath, + destinationSlug: destinationSlug, + destinationNamespace: destinationNamespace, + }, + ]; + + const sourceConfig = { + url: sourceUrl, + access_token: sourceAccessToken, + }; + + try { + await api.Migrations.create(sourceConfig, migrationEntity); + } catch (e: any) { + throw new InputError( + `Failed to transfer repo ${sourceFullPath}. Make sure that ${sourceFullPath} exists in ${sourceUrl}, and token has enough rights.\nError: ${e}`, + ); + } + + ctx.output( + 'importedRepoUrl', + `${destinationHost}/${destinationNamespace}/${destinationSlug}`, + ); + }, + }); +}; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts index c601c4656e..54a91a91bf 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts @@ -15,6 +15,7 @@ */ export * from './gitlab'; export * from './gitlabGroupEnsureExists'; +export * from './gitlabProjectMigrate'; export * from './gitlabIssueCreate'; export * from './gitlabIssueEdit'; export * from './gitlabMergeRequest'; diff --git a/plugins/scaffolder-backend-module-gitlab/src/module.ts b/plugins/scaffolder-backend-module-gitlab/src/module.ts index 9f18532cdf..f96d6d7c45 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/module.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/module.ts @@ -24,6 +24,7 @@ import { } from '@backstage/plugin-scaffolder-node/alpha'; import { createGitlabGroupEnsureExistsAction, + createGitlabProjectMigrateAction, createGitlabIssueAction, createGitlabProjectAccessTokenAction, createGitlabProjectDeployTokenAction, @@ -55,6 +56,7 @@ export const gitlabModule = createBackendModule({ scaffolder.addActions( createGitlabGroupEnsureExistsAction({ integrations }), + createGitlabProjectMigrateAction({ integrations }), createGitlabIssueAction({ integrations }), createGitlabProjectAccessTokenAction({ integrations }), createGitlabProjectDeployTokenAction({ integrations }),