From 6a694ce98e323bbee6ba3a7523c37ceb01badad1 Mon Sep 17 00:00:00 2001 From: ohjongsung Date: Mon, 22 May 2023 16:56:38 +0900 Subject: [PATCH] Add a scaffolder action that pull-requests for Bitbucket Server Signed-off-by: ohjongsung --- .changeset/rude-bags-count.md | 5 + .../actions/builtin/createBuiltinActions.ts | 5 + .../bitbucketServerPullRequest.test.ts | 389 ++++++++++++++++++ .../publish/bitbucketServerPullRequest.ts | 262 ++++++++++++ .../actions/builtin/publish/index.ts | 1 + 5 files changed, 662 insertions(+) create mode 100644 .changeset/rude-bags-count.md create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServerPullRequest.test.ts create mode 100644 plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServerPullRequest.ts diff --git a/.changeset/rude-bags-count.md b/.changeset/rude-bags-count.md new file mode 100644 index 0000000000..e2498f4abf --- /dev/null +++ b/.changeset/rude-bags-count.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +--- + +Add a scaffolder action that pull-requests for bitbucket server diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index 747b5cdaed..28a530eb04 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -52,6 +52,7 @@ import { createPublishBitbucketAction, createPublishBitbucketCloudAction, createPublishBitbucketServerAction, + createPublishBitbucketServerPullRequestAction, createPublishGerritAction, createPublishGerritReviewAction, createPublishGithubAction, @@ -162,6 +163,10 @@ export const createBuiltinActions = ( integrations, config, }), + createPublishBitbucketServerPullRequestAction({ + integrations, + config, + }), createPublishAzureAction({ integrations, config, diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServerPullRequest.test.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServerPullRequest.test.ts new file mode 100644 index 0000000000..af3999097f --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServerPullRequest.test.ts @@ -0,0 +1,389 @@ +/* + * Copyright 2021 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. + */ + +jest.mock('../helpers', () => { + return { + initRepoAndPush: jest.fn().mockResolvedValue({ + commitHash: '220f19cc36b551763d157f1b5e4a4b446165dbd6', + }), + commitAndPushRepo: jest.fn().mockResolvedValue({ + commitHash: '220f19cc36b551763d157f1b5e4a4b446165dbd6', + }), + }; +}); + +import { createPublishBitbucketServerPullRequestAction } from './bitbucketServerPullRequest'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { setupRequestMockHandlers } from '@backstage/backend-test-utils'; +import { ScmIntegrations } from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; +import { getVoidLogger } from '@backstage/backend-common'; +import { PassThrough } from 'stream'; + +describe('publish:bitbucketServer:pull-request', () => { + const config = new ConfigReader({ + integrations: { + bitbucketServer: [ + { + host: 'hosted.bitbucket.com', + token: 'thing', + apiBaseUrl: 'https://hosted.bitbucket.com/rest/api/1.0', + }, + { + host: 'basic-auth.bitbucket.com', + username: 'test-user', + password: 'test-password', + apiBaseUrl: 'https://basic-auth.bitbucket.com/rest/api/1.0', + }, + { + host: 'no-credentials.bitbucket.com', + }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + const action = createPublishBitbucketServerPullRequestAction({ + integrations, + config, + }); + const mockContext = { + input: { + repoUrl: 'hosted.bitbucket.com?project=project&repo=repo', + title: 'Add Scaffolder actions for Bitbucket Server', + description: + 'I just made a Pull Request that Add Scaffolder actions for Bitbucket Server', + targetBranch: 'master', + sourceBranch: 'develop', + }, + workspacePath: 'wsp', + logger: getVoidLogger(), + logStream: new PassThrough(), + output: jest.fn(), + createTemporaryDirectory: jest.fn(), + }; + const responseOfBranches = { + size: 3, + limit: 25, + isLastPage: true, + values: [ + { + id: 'refs/heads/master', + displayId: 'master', + type: 'BRANCH', + latestCommit: 'b1041e3f9b071b3d5cacd6826b7549cd624418f1', + latestChangeset: 'b1041e3f9b071b3d5cacd6826b7549cd624418f1', + isDefault: true, + }, + { + id: 'refs/heads/develop', + displayId: 'develop', + type: 'BRANCH', + latestCommit: '98e21148205367aeb11c25a52eaca3c2945253fa', + latestChangeset: '98e21148205367aeb11c25a52eaca3c2945253fa', + isDefault: false, + }, + { + id: 'refs/heads/develop-2', + displayId: 'develop-2', + type: 'BRANCH', + latestCommit: 'b1041e3f9b071b3d5cacd6826b7549cd624418f1', + latestChangeset: 'b1041e3f9b071b3d5cacd6826b7549cd624418f1', + isDefault: false, + }, + ], + start: 0, + }; + const responseOfPullRequests = { + id: 19, + version: 0, + title: 'Test for bitbucket server pull-requests', + description: 'Test for bitbucket server pull-requests', + state: 'OPEN', + open: true, + closed: false, + createdDate: 1684200289521, + updatedDate: 1684200289521, + fromRef: { + id: 'refs/heads/develop', + displayId: 'develop', + latestCommit: '98e21148205367aeb11c25a52eaca3c2945253fa', + type: 'BRANCH', + repository: { + slug: 'repo', + id: 1812, + name: 'repo', + description: 'This is a test repo', + hierarchyId: '1da8822903a9b11a27b8', + scmId: 'git', + state: 'AVAILABLE', + statusMessage: 'Available', + forkable: true, + project: {}, + public: false, + links: {}, + }, + }, + toRef: { + id: 'refs/heads/master', + displayId: 'master', + latestCommit: 'b1041e3f9b071b3d5cacd6826b7549cd624418f1', + type: 'BRANCH', + repository: { + slug: 'repo', + id: 1812, + name: 'repo', + description: 'This is a test repo', + hierarchyId: '1da8822903a9b11a27b8', + scmId: 'git', + state: 'AVAILABLE', + statusMessage: 'Available', + forkable: true, + project: {}, + public: false, + links: {}, + }, + }, + locked: false, + author: { + user: { + name: 'test-user', + emailAddress: 'test-user@sample.com', + id: 2944, + displayName: 'test-user', + active: true, + slug: 'test-user', + type: 'NORMAL', + links: {}, + }, + role: 'AUTHOR', + approved: false, + status: 'UNAPPROVED', + }, + reviewers: [], + participants: [], + links: { + self: [ + { + href: 'https://hosted.bitbucket.com/projects/project/repos/repo/pull-requests/1', + }, + ], + }, + }; + const handlers = [ + rest.get( + 'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos/repo/branches', + (_, res, ctx) => { + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(responseOfBranches), + ); + }, + ), + rest.post( + 'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos/repo/pull-requests', + (_, res, ctx) => { + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json(responseOfPullRequests), + ); + }, + ), + ]; + + const server = setupServer(); + setupRequestMockHandlers(server); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it('should throw an error when the repoUrl is not well formed', async () => { + await expect( + action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'hosted.bitbucket.com?repo=repo', + }, + }), + ).rejects.toThrow(/missing project/); + + await expect( + action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'hosted.bitbucket.com?project=project', + }, + }), + ).rejects.toThrow(/missing repo/); + }); + + it('should throw if there is no integration config provided', async () => { + await expect( + action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'missing.com?project=project&repo=repo', + }, + }), + ).rejects.toThrow(/No matching integration configuration/); + }); + + it('should throw if there no credentials in the integration config that is returned', async () => { + await expect( + action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'no-credentials.bitbucket.com?project=project&repo=repo', + }, + }), + ).rejects.toThrow( + /Authorization has not been provided for no-credentials.bitbucket.com/, + ); + }); + + it('should call the correct APIs with token', async () => { + expect.assertions(3); + server.use( + rest.get( + 'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos/repo/branches', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe('Bearer thing'); + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(responseOfBranches), + ); + }, + ), + rest.post( + 'https://hosted.bitbucket.com/rest/api/1.0/projects/project/repos/repo/pull-requests', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe('Bearer thing'); + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json(responseOfPullRequests), + ); + }, + ), + ); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'hosted.bitbucket.com?project=project&repo=repo', + }, + }); + }); + + it('should call the correct APIs with basic auth', async () => { + expect.assertions(3); + server.use( + rest.get( + 'https://basic-auth.bitbucket.com/rest/api/1.0/projects/project/repos/repo/branches', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe( + 'Basic dGVzdC11c2VyOnRlc3QtcGFzc3dvcmQ=', + ); + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(responseOfBranches), + ); + }, + ), + rest.post( + 'https://basic-auth.bitbucket.com/rest/api/1.0/projects/project/repos/repo/pull-requests', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe( + 'Basic dGVzdC11c2VyOnRlc3QtcGFzc3dvcmQ=', + ); + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json(responseOfPullRequests), + ); + }, + ), + ); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'basic-auth.bitbucket.com?project=project&repo=repo', + }, + }); + }); + + it('should work if the token is provided through ctx.input', async () => { + expect.assertions(3); + const token = 'user-token'; + server.use( + rest.get( + 'https://no-credentials.bitbucket.com/rest/api/1.0/projects/project/repos/repo/branches', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe(`Bearer ${token}`); + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(responseOfBranches), + ); + }, + ), + rest.post( + 'https://no-credentials.bitbucket.com/rest/api/1.0/projects/project/repos/repo/pull-requests', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe(`Bearer ${token}`); + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json(responseOfPullRequests), + ); + }, + ), + ); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'no-credentials.bitbucket.com?project=project&repo=repo', + token: token, + }, + }); + }); + + it('should call outputs with the correct urls', async () => { + server.use(...handlers); + + await action.handler(mockContext); + + expect(mockContext.output).toHaveBeenCalledWith( + 'pullRequestUrl', + 'https://hosted.bitbucket.com/projects/project/repos/repo/pull-requests/1', + ); + }); +}); diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServerPullRequest.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServerPullRequest.ts new file mode 100644 index 0000000000..cfcccb05ce --- /dev/null +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/bitbucketServerPullRequest.ts @@ -0,0 +1,262 @@ +/* + * Copyright 2021 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 { InputError } from '@backstage/errors'; +import { + getBitbucketServerRequestOptions, + ScmIntegrationRegistry, +} from '@backstage/integration'; +import { createTemplateAction } from '@backstage/plugin-scaffolder-node'; +import fetch, { RequestInit, Response } from 'node-fetch'; +import { parseRepoUrl } from './util'; +import { Config } from '@backstage/config'; + +const createPullRequest = async (opts: { + project: string; + repo: string; + title: string; + description?: string; + toRef: object; + fromRef: object; + authorization: string; + apiBaseUrl: string; +}) => { + const { + project, + repo, + title, + description, + toRef, + fromRef, + authorization, + apiBaseUrl, + } = opts; + + let response: Response; + const data: RequestInit = { + method: 'POST', + body: JSON.stringify({ + title: title, + description: description, + state: 'OPEN', + open: true, + closed: false, + locked: true, + toRef: toRef, + fromRef: fromRef, + }), + headers: { + Authorization: authorization, + 'Content-Type': 'application/json', + }, + }; + + try { + response = await fetch( + `${apiBaseUrl}/projects/${project}/repos/${repo}/pull-requests`, + data, + ); + } catch (e) { + throw new Error(`Unable to create pull-reqeusts, ${e}`); + } + + if (response.status !== 201) { + throw new Error( + `Unable to create pull requests, ${response.status} ${ + response.statusText + }, ${await response.text()}`, + ); + } + + const r = await response.json(); + return `${r.links.self[0].href}`; +}; +const findBranches = async (opts: { + project: string; + repo: string; + branchName: string; + authorization: string; + apiBaseUrl: string; +}) => { + const { project, repo, branchName, authorization, apiBaseUrl } = opts; + + let response: Response; + const options: RequestInit = { + method: 'GET', + headers: { + Authorization: authorization, + 'Content-Type': 'application/json', + }, + }; + + try { + response = await fetch( + `${apiBaseUrl}/projects/${project}/repos/${repo}/branches?boostMatches=true&filterText=${branchName}`, + options, + ); + } catch (e) { + throw new Error(`Unable to get branches, ${e}`); + } + + if (response.status !== 200) { + throw new Error( + `Unable to get branches, ${response.status} ${ + response.statusText + }, ${await response.text()}`, + ); + } + + const r = await response.json(); + let branch; + for (const object of r.values) { + if (object.displayId === branchName) { + branch = object; + break; + } + } + + return branch; +}; + +export function createPublishBitbucketServerPullRequestAction(options: { + integrations: ScmIntegrationRegistry; + config: Config; +}) { + const { integrations } = options; + + return createTemplateAction<{ + repoUrl: string; + title: string; + description?: string; + targetBranch: string; + sourceBranch: string; + token?: string; + }>({ + id: 'publish:bitbucketServer:pull-request', + schema: { + input: { + type: 'object', + required: ['repoUrl'], + properties: { + repoUrl: { + title: 'Repository Location', + type: 'string', + }, + title: { + title: 'Pull Request Name', + type: 'string', + description: 'The name for the pull request', + }, + description: { + title: 'Pull Request Description', + type: 'string', + description: 'The description of the pull request', + }, + targetBranch: { + title: 'Target Branch', + type: 'string', + description: 'The description of the pull request', + }, + sourceBranch: { + title: 'Source Branch', + type: 'string', + description: 'The description of the pull request', + }, + token: { + title: 'Authentication Token', + type: 'string', + description: + 'The token to use for authorization to BitBucket Server', + }, + }, + }, + output: { + type: 'object', + properties: { + pullRequestUrl: { + title: 'A URL to the pull request with the provider', + type: 'string', + }, + }, + }, + }, + async handler(ctx) { + const { repoUrl, title, description, targetBranch, sourceBranch } = + ctx.input; + + const { project, repo, host } = parseRepoUrl(repoUrl, integrations); + + if (!project) { + throw new InputError( + `Invalid URL provider was included in the repo URL to create ${ctx.input.repoUrl}, missing project`, + ); + } + + const integrationConfig = integrations.bitbucketServer.byHost(host); + if (!integrationConfig) { + throw new InputError( + `No matching integration configuration for host ${host}, please check your integrations config`, + ); + } + + const token = ctx.input.token ?? integrationConfig.config.token; + + const authConfig = { + ...integrationConfig.config, + ...{ token }, + }; + + const reqOpts = getBitbucketServerRequestOptions(authConfig); + const authorization = reqOpts.headers.Authorization; + if (!authorization) { + throw new Error( + `Authorization has not been provided for ${integrationConfig.config.host}. Please add either (a) a user login auth token, or (b) a token or (c) username + password to the integration config.`, + ); + } + + const apiBaseUrl = integrationConfig.config.apiBaseUrl; + + const toRef = await findBranches({ + project, + repo, + branchName: targetBranch, + authorization, + apiBaseUrl, + }); + + const fromRef = await findBranches({ + project, + repo, + branchName: sourceBranch, + authorization, + apiBaseUrl, + }); + + const pullRequestUrl = await createPullRequest({ + project, + repo, + title, + description, + toRef, + fromRef, + authorization, + apiBaseUrl, + }); + + ctx.output('pullRequestUrl', pullRequestUrl); + }, + }); +} diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts index a8a40ab1df..bed3a2316c 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/publish/index.ts @@ -18,6 +18,7 @@ export { createPublishAzureAction } from './azure'; export { createPublishBitbucketAction } from './bitbucket'; export { createPublishBitbucketCloudAction } from './bitbucketCloud'; export { createPublishBitbucketServerAction } from './bitbucketServer'; +export { createPublishBitbucketServerPullRequestAction } from './bitbucketServerPullRequest'; export { createPublishGerritAction } from './gerrit'; export { createPublishGerritReviewAction } from './gerritReview'; export { createPublishGithubAction } from './github';