diff --git a/.changeset/rich-poets-stare.md b/.changeset/rich-poets-stare.md new file mode 100644 index 0000000000..3c6f9a5035 --- /dev/null +++ b/.changeset/rich-poets-stare.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-backend-module-gitlab': patch +'@backstage/plugin-scaffolder-backend': minor +--- + +Add `gitlab:repo:push` scaffolder action to push files to arbitrary branch without creating a Merge Request diff --git a/plugins/scaffolder-backend-module-gitlab/api-report.md b/plugins/scaffolder-backend-module-gitlab/api-report.md index 9ff9fb3dfc..9b3eec5cdf 100644 --- a/plugins/scaffolder-backend-module-gitlab/api-report.md +++ b/plugins/scaffolder-backend-module-gitlab/api-report.md @@ -104,6 +104,22 @@ export const createGitlabProjectVariableAction: (options: { JsonObject >; +// @public +export const createGitlabRepoPushAction: (options: { + integrations: ScmIntegrationRegistry; +}) => TemplateAction< + { + repoUrl: string; + branchName: string; + commitMessage: string; + sourcePath?: string | undefined; + targetPath?: string | undefined; + token?: string | undefined; + commitAction?: 'update' | 'delete' | 'create' | undefined; + }, + JsonObject +>; + // @public export function createPublishGitlabAction(options: { integrations: ScmIntegrationRegistry; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.ts index f6c4734729..86f69ee357 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabMergeRequest.ts @@ -19,12 +19,12 @@ import { parseRepoUrl, serializeDirectoryContents, } from '@backstage/plugin-scaffolder-node'; -import { Gitlab } from '@gitbeaker/node'; import { Types } from '@gitbeaker/core'; import path from 'path'; import { ScmIntegrationRegistry } from '@backstage/integration'; import { InputError } from '@backstage/errors'; import { resolveSafeChildPath } from '@backstage/backend-common'; +import { createGitlabApi } from './helpers'; /** * Create a new action that creates a gitlab merge request. @@ -158,33 +158,16 @@ export const createPublishGitlabMergeRequestAction = (options: { targetPath, sourcePath, title, - token: providedToken, + token, } = ctx.input; - const { host, owner, repo, project } = parseRepoUrl( - repoUrl, - integrations, - ); + const { owner, repo, project } = parseRepoUrl(repoUrl, integrations); const repoID = project ? project : `${owner}/${repo}`; - const integrationConfig = integrations.gitlab.byHost(host); - - if (!integrationConfig) { - throw new InputError( - `No matching integration configuration for host ${host}, please check your integrations config`, - ); - } - - if (!integrationConfig.config.token && !providedToken) { - throw new InputError(`No token available for host ${host}`); - } - - const token = providedToken ?? integrationConfig.config.token!; - const tokenType = providedToken ? 'oauthToken' : 'token'; - - const api = new Gitlab({ - host: integrationConfig.config.baseUrl, - [tokenType]: token, + const api = createGitlabApi({ + integrations, + token, + repoUrl, }); let assigneeId = undefined; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts new file mode 100644 index 0000000000..840f506540 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.test.ts @@ -0,0 +1,436 @@ +/* + * 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 { createRootLogger, getRootLogger } from '@backstage/backend-common'; +import { ConfigReader } from '@backstage/config'; +import { ScmIntegrations } from '@backstage/integration'; +import { TemplateAction } from '@backstage/plugin-scaffolder-node'; +import { Writable } from 'stream'; +import { createMockDirectory } from '@backstage/backend-test-utils'; +import { createGitlabRepoPushAction } from './gitlabRepoPush'; + +// Make sure root logger is initialized ahead of FS mock +createRootLogger(); + +const mockGitlabClient = { + Projects: { + create: jest.fn(), + show: jest.fn(async (_: any) => { + return { + default_branch: 'main', + }; + }), + }, + Branches: { + create: jest.fn(), + show: jest.fn(), + }, + Commits: { + create: jest.fn(async (_: any) => ({ + id: 'bb6bce457ed069a38ef8d16ef38602972c7735c5', + })), + }, +}; + +jest.mock('@gitbeaker/node', () => ({ + Gitlab: class { + constructor() { + return mockGitlabClient; + } + }, +})); + +describe('createGitLabCommit', () => { + let instance: TemplateAction; + + const mockDir = createMockDirectory(); + const workspacePath = mockDir.resolve('workspace'); + + beforeEach(() => { + mockDir.clear(); + + 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 = createGitlabRepoPushAction({ integrations }); + }); + + describe('createGitLabCommitWithoutCommitAction', () => { + it('default commitAction is create', async () => { + const input = { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + commitMessage: 'Create my new commit', + branchName: 'some-branch', + }; + mockDir.setContent({ + [workspacePath]: { + 'foo.txt': 'Hello there!', + }, + }); + const ctx = { + createTemporaryDirectory: jest.fn(), + output: jest.fn(), + logger: getRootLogger(), + logStream: new Writable(), + input, + workspacePath, + }; + await instance.handler(ctx); + + expect(mockGitlabClient.Branches.create).toHaveBeenCalledTimes(0); + expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith( + 'owner/repo', + 'some-branch', + 'Create my new commit', + [ + { + action: 'create', + filePath: 'foo.txt', + content: 'SGVsbG8gdGhlcmUh', + encoding: 'base64', + execute_filemode: false, + }, + ], + ); + expect(ctx.output).toHaveBeenCalledWith( + 'commitHash', + 'bb6bce457ed069a38ef8d16ef38602972c7735c5', + ); + }); + }); + + describe('createGitLabCommitWithCommitAction', () => { + it('commitAction is create when create is passed in options', async () => { + const input = { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + commitMessage: 'Create my new commit', + branchName: 'some-branch', + commitAction: 'create', + }; + mockDir.setContent({ + [workspacePath]: { + 'foo.txt': 'Hello there!', + }, + }); + + const ctx = { + createTemporaryDirectory: jest.fn(), + output: jest.fn(), + logger: getRootLogger(), + logStream: new Writable(), + input, + workspacePath, + }; + await instance.handler(ctx); + + expect(mockGitlabClient.Branches.create).toHaveBeenCalledTimes(0); + expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith( + 'owner/repo', + 'some-branch', + 'Create my new commit', + [ + { + action: 'create', + filePath: 'foo.txt', + content: 'SGVsbG8gdGhlcmUh', + encoding: 'base64', + execute_filemode: false, + }, + ], + ); + expect(ctx.output).toHaveBeenCalledWith( + 'commitHash', + 'bb6bce457ed069a38ef8d16ef38602972c7735c5', + ); + }); + + it('commitAction is update when update is passed in options', async () => { + const input = { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + commitMessage: 'Create my new commit', + branchName: 'some-branch', + commitAction: 'update', + }; + mockDir.setContent({ + [workspacePath]: { + 'foo.txt': 'Hello there!', + }, + }); + + const ctx = { + createTemporaryDirectory: jest.fn(), + output: jest.fn(), + logger: getRootLogger(), + logStream: new Writable(), + input, + workspacePath, + }; + await instance.handler(ctx); + + expect(mockGitlabClient.Branches.create).toHaveBeenCalledTimes(0); + expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith( + 'owner/repo', + 'some-branch', + 'Create my new commit', + [ + { + action: 'update', + filePath: 'foo.txt', + content: 'SGVsbG8gdGhlcmUh', + encoding: 'base64', + execute_filemode: false, + }, + ], + ); + expect(ctx.output).toHaveBeenCalledWith( + 'commitHash', + 'bb6bce457ed069a38ef8d16ef38602972c7735c5', + ); + }); + + it('commitAction is delete when delete is passed in options', async () => { + const input = { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + commitMessage: 'Create my new commit', + branchName: 'some-branch', + commitAction: 'delete', + }; + mockDir.setContent({ + [workspacePath]: { + 'foo.txt': 'Hello there!', + }, + }); + + const ctx = { + createTemporaryDirectory: jest.fn(), + output: jest.fn(), + logger: getRootLogger(), + logStream: new Writable(), + input, + workspacePath, + }; + await instance.handler(ctx); + + expect(mockGitlabClient.Branches.create).toHaveBeenCalledTimes(0); + expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith( + 'owner/repo', + 'some-branch', + 'Create my new commit', + [ + { + action: 'delete', + filePath: 'foo.txt', + content: 'SGVsbG8gdGhlcmUh', + encoding: 'base64', + execute_filemode: false, + }, + ], + ); + expect(ctx.output).toHaveBeenCalledWith( + 'commitHash', + 'bb6bce457ed069a38ef8d16ef38602972c7735c5', + ); + }); + }); + + describe('with sourcePath', () => { + it('creates a commit with only relevant files', async () => { + const input = { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + commitMessage: 'Create my new commit', + branchName: 'some-branch', + sourcePath: 'source', + commitAction: 'create', + }; + + mockDir.setContent({ + [workspacePath]: { + source: { 'foo.txt': 'Hello there!' }, + irrelevant: { 'bar.txt': 'Nothing to see here' }, + }, + }); + + const ctx = { + createTemporaryDirectory: jest.fn(), + output: jest.fn(), + logger: getRootLogger(), + logStream: new Writable(), + input, + workspacePath, + }; + + await instance.handler(ctx); + + expect(mockGitlabClient.Branches.create).toHaveBeenCalledTimes(0); + expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith( + 'owner/repo', + 'some-branch', + 'Create my new commit', + [ + { + action: 'create', + filePath: 'foo.txt', + content: 'SGVsbG8gdGhlcmUh', + encoding: 'base64', + execute_filemode: false, + }, + ], + ); + expect(ctx.output).toHaveBeenCalledWith( + 'commitHash', + 'bb6bce457ed069a38ef8d16ef38602972c7735c5', + ); + }); + + it('creates a commit with only relevant files placed under different targetPath', async () => { + const input = { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + commitMessage: 'Create my new commit', + branchName: 'some-branch', + sourcePath: 'source', + targetPath: 'target', + commitAction: 'create', + }; + + mockDir.setContent({ + [workspacePath]: { + source: { 'foo.txt': 'Hello there!' }, + irrelevant: { 'bar.txt': 'Nothing to see here' }, + }, + }); + + const ctx = { + createTemporaryDirectory: jest.fn(), + output: jest.fn(), + logger: getRootLogger(), + logStream: new Writable(), + input, + workspacePath, + }; + + await instance.handler(ctx); + + expect(mockGitlabClient.Branches.create).toHaveBeenCalledTimes(0); + expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith( + 'owner/repo', + 'some-branch', + 'Create my new commit', + [ + { + action: 'create', + filePath: 'target/foo.txt', + content: 'SGVsbG8gdGhlcmUh', + encoding: 'base64', + execute_filemode: false, + }, + ], + ); + expect(ctx.output).toHaveBeenCalledWith( + 'commitHash', + 'bb6bce457ed069a38ef8d16ef38602972c7735c5', + ); + }); + + it('should not allow to use files outside of the workspace', async () => { + const input = { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + commitMessage: 'Create my new commit', + branchName: 'some-branch', + sourcePath: '../../test', + commitAction: 'create', + }; + + const ctx = { + createTemporaryDirectory: jest.fn(), + output: jest.fn(), + logger: getRootLogger(), + logStream: new Writable(), + input, + workspacePath, + }; + + await expect(instance.handler(ctx)).rejects.toThrow( + 'Relative path is not allowed to refer to a directory outside its parent', + ); + }); + }); + + describe('createCommitToBranchThatDoesNotExist', () => { + it('should create a new branch', async () => { + mockGitlabClient.Branches.show.mockRejectedValue({ + response: { + statusCode: 404, + }, + }); + const input = { + repoUrl: 'gitlab.com?repo=repo&owner=owner', + commitMessage: 'Create my new commit', + branchName: 'some-branch', + }; + mockDir.setContent({ + [workspacePath]: { + 'foo.txt': 'Hello there!', + }, + }); + const ctx = { + createTemporaryDirectory: jest.fn(), + output: jest.fn(), + logger: getRootLogger(), + logStream: new Writable(), + input, + workspacePath, + }; + await instance.handler(ctx); + + expect(mockGitlabClient.Branches.create).toHaveBeenCalledWith( + 'owner/repo', + 'some-branch', + 'main', + ); + expect(mockGitlabClient.Commits.create).toHaveBeenCalledWith( + 'owner/repo', + 'some-branch', + 'Create my new commit', + [ + { + action: 'create', + filePath: 'foo.txt', + content: 'SGVsbG8gdGhlcmUh', + encoding: 'base64', + execute_filemode: false, + }, + ], + ); + expect(ctx.output).toHaveBeenCalledWith( + 'commitHash', + 'bb6bce457ed069a38ef8d16ef38602972c7735c5', + ); + }); + }); +}); diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.ts new file mode 100644 index 0000000000..330d0f6f08 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/gitlabRepoPush.ts @@ -0,0 +1,194 @@ +/* + * 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 { + createTemplateAction, + parseRepoUrl, + serializeDirectoryContents, +} from '@backstage/plugin-scaffolder-node'; +import { Types } from '@gitbeaker/core'; +import path from 'path'; +import { ScmIntegrationRegistry } from '@backstage/integration'; +import { InputError } from '@backstage/errors'; +import { resolveSafeChildPath } from '@backstage/backend-common'; +import { createGitlabApi } from './helpers'; + +/** + * Create a new action that commits into a gitlab repository. + * + * @public + */ +export const createGitlabRepoPushAction = (options: { + integrations: ScmIntegrationRegistry; +}) => { + const { integrations } = options; + + return createTemplateAction<{ + repoUrl: string; + branchName: string; + commitMessage: string; + sourcePath?: string; + targetPath?: string; + token?: string; + commitAction?: 'create' | 'delete' | 'update'; + }>({ + id: 'gitlab:repo:push', + schema: { + input: { + required: ['repoUrl', 'branchName', 'commitMessage'], + type: 'object', + properties: { + repoUrl: { + type: 'string', + title: 'Repository 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`, + }, + branchName: { + type: 'string', + title: 'Source Branch Name', + description: 'The branch name for the commit', + }, + commitMessage: { + type: 'string', + title: 'Commit Message', + description: `The commit message`, + }, + sourcePath: { + type: 'string', + title: 'Working Subdirectory', + description: + 'Subdirectory of working directory to copy changes from', + }, + targetPath: { + type: 'string', + title: 'Repository Subdirectory', + description: 'Subdirectory of repository to apply changes to', + }, + token: { + title: 'Authentication Token', + type: 'string', + description: 'The token to use for authorization to GitLab', + }, + commitAction: { + title: 'Commit action', + type: 'string', + enum: ['create', 'update', 'delete'], + description: + 'The action to be used for git commit. Defaults to create.', + }, + }, + }, + output: { + type: 'object', + properties: { + projectid: { + title: 'Gitlab Project id/Name(slug)', + type: 'string', + }, + projectPath: { + title: 'Gitlab Project path', + type: 'string', + }, + commitHash: { + title: 'The git commit hash of the commit', + type: 'string', + }, + }, + }, + }, + async handler(ctx) { + const { + branchName, + repoUrl, + targetPath, + sourcePath, + token, + commitAction, + } = ctx.input; + + const { owner, repo, project } = parseRepoUrl(repoUrl, integrations); + const repoID = project ? project : `${owner}/${repo}`; + + const api = createGitlabApi({ + integrations, + token, + repoUrl, + }); + + let fileRoot: string; + if (sourcePath) { + fileRoot = resolveSafeChildPath(ctx.workspacePath, sourcePath); + } else { + fileRoot = ctx.workspacePath; + } + + const fileContents = await serializeDirectoryContents(fileRoot, { + gitignore: true, + }); + + const actions: Types.CommitAction[] = fileContents.map(file => ({ + action: commitAction ?? 'create', + filePath: targetPath + ? path.posix.join(targetPath, file.path) + : file.path, + encoding: 'base64', + content: file.content.toString('base64'), + execute_filemode: file.executable, + })); + + let branchExists = false; + try { + await api.Branches.show(repoID, branchName); + branchExists = true; + } catch (e: any) { + if (e.response?.statusCode !== 404) { + throw new InputError( + `Failed to check status of branch '${branchName}'. Please make sure that branch already exists or Backstage has permissions to create one. ${e}`, + ); + } + } + + if (!branchExists) { + // create a branch using the default branch as ref + try { + const projects = await api.Projects.show(repoID); + const { default_branch: defaultBranch } = projects; + await api.Branches.create(repoID, branchName, String(defaultBranch)); + } catch (e) { + throw new InputError( + `The branch '${branchName}' was not found and creation failed with error. Please make sure that branch already exists or Backstage has permissions to create one. ${e}`, + ); + } + } + + try { + const commit = await api.Commits.create( + repoID, + branchName, + ctx.input.commitMessage, + actions, + ); + ctx.output('projectid', repoID); + ctx.output('projectPath', repoID); + ctx.output('commitHash', commit.id); + } catch (e) { + throw new InputError( + `Committing the changes to ${branchName} failed. Please check that none of the files created by the template already exists. ${e}`, + ); + } + }, + }); +}; diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/helpers.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/helpers.ts new file mode 100644 index 0000000000..1d12a6d394 --- /dev/null +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/helpers.ts @@ -0,0 +1,50 @@ +/* + * 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 { parseRepoUrl } from '@backstage/plugin-scaffolder-node'; +import { InputError } from '@backstage/errors'; +import { Gitlab } from '@gitbeaker/node'; +import { ScmIntegrationRegistry } from '@backstage/integration'; +import { Resources } from '@gitbeaker/core'; + +export function createGitlabApi(options: { + integrations: ScmIntegrationRegistry; + token?: string; + repoUrl: string; +}): Resources.Gitlab { + const { integrations, token: providedToken, repoUrl } = options; + + const { host } = parseRepoUrl(repoUrl, integrations); + + const integrationConfig = integrations.gitlab.byHost(host); + + if (!integrationConfig) { + throw new InputError( + `No matching integration configuration for host ${host}, please check your integrations config`, + ); + } + + if (!integrationConfig.config.token && !providedToken) { + throw new InputError(`No token available for host ${host}`); + } + + const token = providedToken ?? integrationConfig.config.token!; + const tokenType = providedToken ? 'oauthToken' : 'token'; + + return new Gitlab({ + host: integrationConfig.config.baseUrl, + [tokenType]: token, + }); +} diff --git a/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts b/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts index 237ef7c271..4a5ca06c98 100644 --- a/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts +++ b/plugins/scaffolder-backend-module-gitlab/src/actions/index.ts @@ -20,3 +20,4 @@ export * from './createGitlabProjectVariableAction'; export * from './createGitlabIssueAction'; export * from './gitlab'; export * from './gitlabMergeRequest'; +export * from './gitlabRepoPush'; diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index a767b3cd17..02b96e9fa7 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -70,6 +70,7 @@ import { import { createPublishGitlabAction, + createGitlabRepoPushAction, createPublishGitlabMergeRequestAction, } from '@backstage/plugin-scaffolder-backend-module-gitlab'; @@ -166,6 +167,9 @@ export const createBuiltinActions = ( createPublishGitlabMergeRequestAction({ integrations, }), + createGitlabRepoPushAction({ + integrations, + }), createPublishBitbucketAction({ integrations, config,