diff --git a/.changeset/fuzzy-fireants-crash.md b/.changeset/fuzzy-fireants-crash.md new file mode 100644 index 0000000000..1feec6232c --- /dev/null +++ b/.changeset/fuzzy-fireants-crash.md @@ -0,0 +1,6 @@ +--- +'@backstage/plugin-scaffolder-backend': minor +'@backstage/plugin-scaffolder-backend-module-bitbucket-cloud': patch +--- + +Added scaffolder action publish:bitbucketCloud:pull-request diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/README.md b/plugins/scaffolder-backend-module-bitbucket-cloud/README.md index 0d616f2670..462b97e74f 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/README.md +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/README.md @@ -9,3 +9,4 @@ _This plugin was created through the Backstage CLI_ - `publish:bitbucketCloud` - `bitbucket:pipelines:run` +- `publish:bitbucketCloud:pull-request` diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/api-report.md b/plugins/scaffolder-backend-module-bitbucket-cloud/api-report.md index 90a8cbc29f..b853023b07 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/api-report.md +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/api-report.md @@ -42,4 +42,22 @@ export function createPublishBitbucketCloudAction(options: { }, JsonObject >; + +// @public +export function createPublishBitbucketCloudPullRequestAction(options: { + integrations: ScmIntegrationRegistry; + config: Config; +}): TemplateAction< + { + repoUrl: string; + title: string; + description?: string | undefined; + targetBranch?: string | undefined; + sourceBranch: string; + token?: string | undefined; + gitAuthorName?: string | undefined; + gitAuthorEmail?: string | undefined; + }, + JsonObject +>; ``` diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.examples.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.examples.test.ts new file mode 100644 index 0000000000..48ecae0944 --- /dev/null +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.examples.test.ts @@ -0,0 +1,434 @@ +/* + * 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. + */ + +jest.mock('@backstage/plugin-scaffolder-node', () => { + return { + ...jest.requireActual('@backstage/plugin-scaffolder-node'), + initRepoAndPush: jest.fn().mockResolvedValue({ + commitHash: '220f19cc36b551763d157f1b5e4a4b446165dbd6', + }), + commitAndPushRepo: jest.fn().mockResolvedValue({ + commitHash: '220f19cc36b551763d157f1b5e4a4b446165dbd6', + }), + }; +}); + +import { createPublishBitbucketCloudPullRequestAction } from './bitbucketCloudPullRequest'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { registerMswTestHooks } from '@backstage/backend-test-utils'; +import { ScmIntegrations } from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; +import yaml from 'yaml'; +import { examples } from './bitbucketCloudPullRequest.examples'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; + +describe('publish:bitbucketCloud:pull-request', () => { + const config = new ConfigReader({ + integrations: { + bitbucketCloud: [ + { + username: 'test-user', + appPassword: 'test-password', + }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + const action = createPublishBitbucketCloudPullRequestAction({ + integrations, + config, + }); + const mockContext = createMockActionContext({ + input: { + repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo', + title: 'Add Scaffolder actions for Bitbucket Cloud', + description: + 'I just made a Pull Request that Add Scaffolder actions for Bitbucket Cloud', + targetBranch: 'master', + sourceBranch: 'develop', + }, + }); + const responseOfBranches = { + pagelen: 1, + size: 187, + values: [ + { + name: 'issue-9.3/AUI-5343-assistive-class', + links: { + commits: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/commits/issue-9.3/AUI-5343-assistive-class', + }, + self: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/refs/branches/issue-9.3/AUI-5343-assistive-class', + }, + html: { + href: 'https://bitbucket.org/atlassian/aui/branch/issue-9.3/AUI-5343-assistive-class', + }, + }, + default_merge_strategy: 'squash', + merge_strategies: ['merge_commit', 'squash', 'fast_forward'], + type: 'branch', + target: { + hash: 'e5d1cde9069fcb9f0af90403a4de2150c125a148', + repository: { + links: { + self: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui', + }, + html: { href: 'https://bitbucket.org/atlassian/aui' }, + avatar: { + href: 'https://bytebucket.org/ravatar/%7B585074de-7b60-4fd1-81ed-e0bc7fafbda5%7D?ts=86317', + }, + }, + type: 'repository', + name: 'aui', + full_name: 'atlassian/aui', + uuid: '{585074de-7b60-4fd1-81ed-e0bc7fafbda5}', + }, + links: { + self: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/commit/e5d1cde9069fcb9f0af90403a4de2150c125a148', + }, + comments: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/commit/e5d1cde9069fcb9f0af90403a4de2150c125a148/comments', + }, + patch: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/patch/e5d1cde9069fcb9f0af90403a4de2150c125a148', + }, + html: { + href: 'https://bitbucket.org/atlassian/aui/commits/e5d1cde9069fcb9f0af90403a4de2150c125a148', + }, + diff: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/diff/e5d1cde9069fcb9f0af90403a4de2150c125a148', + }, + approve: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/commit/e5d1cde9069fcb9f0af90403a4de2150c125a148/approve', + }, + statuses: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/commit/e5d1cde9069fcb9f0af90403a4de2150c125a148/statuses', + }, + }, + author: { + raw: 'Marcin Konopka ', + type: 'author', + user: { + display_name: 'Marcin Konopka', + uuid: '{47cc24f4-2a05-4420-88fe-0417535a110a}', + links: { + self: { + href: 'https://api.bitbucket.org/2.0/users/%7B47cc24f4-2a05-4420-88fe-0417535a110a%7D', + }, + html: { + href: 'https://bitbucket.org/%7B47cc24f4-2a05-4420-88fe-0417535a110a%7D/', + }, + avatar: { + href: 'https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/initials/MK-1.png', + }, + }, + nickname: 'Marcin Konopka', + type: 'user', + account_id: '60113d2b47a9540069f4de03', + }, + }, + parents: [ + { + hash: '87f7fc92b00464ae47b13ef65c91884e4ac9be51', + type: 'commit', + links: { + self: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/commit/87f7fc92b00464ae47b13ef65c91884e4ac9be51', + }, + html: { + href: 'https://bitbucket.org/atlassian/aui/commits/87f7fc92b00464ae47b13ef65c91884e4ac9be51', + }, + }, + }, + ], + date: '2021-04-13T13:44:49+00:00', + message: 'wip\n', + type: 'commit', + }, + }, + ], + page: 1, + next: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/refs/branches?pagelen=1&page=2', + }; + const responseOfPullRequests = { + type: '', + links: { + self: { href: '', name: '' }, + html: { + href: 'https://bitbucket.org/workspace/repo/pull-requests/1', + name: '', + }, + commits: { href: '', name: '' }, + approve: { href: '', name: '' }, + diff: { href: '', name: '' }, + diffstat: { href: '', name: '' }, + comments: { href: '', name: '' }, + activity: { href: '', name: '' }, + merge: { href: '', name: '' }, + decline: { href: '', name: '' }, + }, + id: 108, + title: '', + rendered: { + title: { raw: '', markup: 'markdown', html: '' }, + description: { raw: '', markup: 'markdown', html: '' }, + reason: { raw: '', markup: 'markdown', html: '' }, + }, + summary: { raw: '', markup: 'markdown', html: '' }, + state: 'OPEN', + author: { type: '' }, + source: { + repository: { type: '' }, + branch: { + name: '', + merge_strategies: ['merge_commit'], + default_merge_strategy: '', + }, + commit: { hash: '' }, + }, + destination: { + repository: { type: '' }, + branch: { + name: '', + merge_strategies: ['merge_commit'], + default_merge_strategy: '', + }, + commit: { hash: '' }, + }, + merge_commit: { hash: '' }, + comment_count: 51, + task_count: 53, + close_source_branch: true, + closed_by: { type: '' }, + reason: '', + created_on: '', + updated_on: '', + reviewers: [{ type: '' }], + participants: [{ type: '' }], + }; + + const server = setupServer(); + registerMswTestHooks(server); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it(`should ${examples[0].description}`, async () => { + expect.assertions(2); + server.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo/refs/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://api.bitbucket.org/2.0/repositories/workspace/repo/pullrequests', + (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, + ...yaml.parse(examples[0].example).steps[0].input, + }, + }); + }); + + it(`should ${examples[1].description}`, async () => { + expect.assertions(2); + server.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo/refs/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://api.bitbucket.org/2.0/repositories/workspace/repo/pullrequests', + (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, + ...yaml.parse(examples[1].example).steps[0].input, + }, + }); + }); + + it(`should ${examples[2].description}`, async () => { + expect.assertions(2); + server.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo/refs/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://api.bitbucket.org/2.0/repositories/workspace/repo/pullrequests', + (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, + ...yaml.parse(examples[2].example).steps[0].input, + }, + }); + }); + + it(`should ${examples[3].description}`, async () => { + expect.assertions(2); + server.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo/refs/branches', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe( + `Bearer ${yaml.parse(examples[3].example).steps[0].input.token}`, + ); + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(responseOfBranches), + ); + }, + ), + rest.post( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo/pullrequests', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe( + `Bearer ${yaml.parse(examples[3].example).steps[0].input.token}`, + ); + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json(responseOfPullRequests), + ); + }, + ), + ); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...yaml.parse(examples[3].example).steps[0].input, + }, + }); + }); + + it(`should ${examples[4].description}`, async () => { + expect.assertions(2); + server.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo/refs/branches', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe( + `Bearer ${yaml.parse(examples[4].example).steps[0].input.token}`, + ); + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(responseOfBranches), + ); + }, + ), + rest.post( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo/pullrequests', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe( + `Bearer ${yaml.parse(examples[4].example).steps[0].input.token}`, + ); + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json(responseOfPullRequests), + ); + }, + ), + ); + + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + ...yaml.parse(examples[4].example).steps[0].input, + }, + }); + }); +}); diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.examples.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.examples.ts new file mode 100644 index 0000000000..75d99c30c2 --- /dev/null +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.examples.ts @@ -0,0 +1,123 @@ +/* + * 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 { TemplateExample } from '@backstage/plugin-scaffolder-node'; +import yaml from 'yaml'; + +export const examples: TemplateExample[] = [ + { + description: + 'Creating pull request on bitbucket cloud with required fields', + example: yaml.stringify({ + steps: [ + { + action: 'publish:bitbucketCloud:pull-request', + id: 'publish-bitbucket-cloud-pull-request-minimal', + name: 'Creating pull request on bitbucket cloud', + input: { + repoUrl: + 'bitbucket.org?workspace=workspace&project=project&repo=repo', + title: 'My pull request', + sourceBranch: 'my-feature-branch', + }, + }, + ], + }), + }, + { + description: + 'Creating pull request on bitbucket cloud with custom descriptions', + example: yaml.stringify({ + steps: [ + { + action: 'publish:bitbucketCloud:pull-request', + id: 'publish-bitbucket-cloud-pull-request-minimal', + name: 'Creating pull request on bitbucket cloud', + input: { + repoUrl: + 'bitbucket.org?workspace=workspace&project=project&repo=repo', + title: 'My pull request', + sourceBranch: 'my-feature-branch', + description: 'This is a detailed description of my pull request', + }, + }, + ], + }), + }, + { + description: + 'Creating pull request on bitbucket cloud with different target branch', + example: yaml.stringify({ + steps: [ + { + action: 'publish:bitbucketCloud:pull-request', + id: 'publish-bitbucket-cloud-pull-request-target-branch', + name: 'Creating pull request on bitbucket cloud', + input: { + repoUrl: + 'bitbucket.org?workspace=workspace&project=project&repo=repo', + title: 'My pull request', + sourceBranch: 'my-feature-branch', + targetBranch: 'development', + }, + }, + ], + }), + }, + { + description: + 'Creating pull request on bitbucket cloud with authorization token', + example: yaml.stringify({ + steps: [ + { + action: 'publish:bitbucketCloud:pull-request', + id: 'publish-bitbucket-cloud-pull-request-minimal', + name: 'Creating pull request on bitbucket cloud', + input: { + repoUrl: + 'bitbucket.org?workspace=workspace&project=project&repo=repo', + title: 'My pull request', + sourceBranch: 'my-feature-branch', + token: 'my-auth-token', + }, + }, + ], + }), + }, + { + description: 'Creating pull request on bitbucket cloud with all fields', + example: yaml.stringify({ + steps: [ + { + action: 'publish:bitbucketCloud:pull-request', + id: 'publish-bitbucket-cloud-pull-request-minimal', + name: 'Creating pull request on bitbucket cloud', + input: { + repoUrl: + 'bitbucket.org?workspace=workspace&project=project&repo=repo', + title: 'My pull request', + sourceBranch: 'my-feature-branch', + targetBranch: 'development', + description: 'This is a detailed description of my pull request', + token: 'my-auth-token', + gitAuthorName: 'test-user', + gitAuthorEmail: 'test-user@sample.com', + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.test.ts new file mode 100644 index 0000000000..b4db3f78c6 --- /dev/null +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.test.ts @@ -0,0 +1,418 @@ +/* + * 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. + */ + +jest.mock('@backstage/plugin-scaffolder-node', () => { + return { + ...jest.requireActual('@backstage/plugin-scaffolder-node'), + initRepoAndPush: jest.fn().mockResolvedValue({ + commitHash: '220f19cc36b551763d157f1b5e4a4b446165dbd6', + }), + commitAndPushRepo: jest.fn().mockResolvedValue({ + commitHash: '220f19cc36b551763d157f1b5e4a4b446165dbd6', + }), + }; +}); + +import { createPublishBitbucketCloudPullRequestAction } from './bitbucketCloudPullRequest'; +import { rest } from 'msw'; +import { setupServer } from 'msw/node'; +import { registerMswTestHooks } from '@backstage/backend-test-utils'; +import { ScmIntegrations } from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; + +describe('publish:bitbucketCloud:pull-request', () => { + const config = new ConfigReader({ + integrations: { + bitbucketCloud: [ + { + username: 'test-user', + appPassword: 'test-password', + }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + const action = createPublishBitbucketCloudPullRequestAction({ + integrations, + config, + }); + const mockContext = createMockActionContext({ + input: { + repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo', + title: 'Add Scaffolder actions for Bitbucket Cloud', + description: + 'I just made a Pull Request that Add Scaffolder actions for Bitbucket Cloud', + targetBranch: 'master', + sourceBranch: 'develop', + }, + }); + const responseOfBranches = { + pagelen: 1, + size: 187, + values: [ + { + name: 'issue-9.3/AUI-5343-assistive-class', + links: { + commits: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/commits/issue-9.3/AUI-5343-assistive-class', + }, + self: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/refs/branches/issue-9.3/AUI-5343-assistive-class', + }, + html: { + href: 'https://bitbucket.org/atlassian/aui/branch/issue-9.3/AUI-5343-assistive-class', + }, + }, + default_merge_strategy: 'squash', + merge_strategies: ['merge_commit', 'squash', 'fast_forward'], + type: 'branch', + target: { + hash: 'e5d1cde9069fcb9f0af90403a4de2150c125a148', + repository: { + links: { + self: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui', + }, + html: { href: 'https://bitbucket.org/atlassian/aui' }, + avatar: { + href: 'https://bytebucket.org/ravatar/%7B585074de-7b60-4fd1-81ed-e0bc7fafbda5%7D?ts=86317', + }, + }, + type: 'repository', + name: 'aui', + full_name: 'atlassian/aui', + uuid: '{585074de-7b60-4fd1-81ed-e0bc7fafbda5}', + }, + links: { + self: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/commit/e5d1cde9069fcb9f0af90403a4de2150c125a148', + }, + comments: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/commit/e5d1cde9069fcb9f0af90403a4de2150c125a148/comments', + }, + patch: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/patch/e5d1cde9069fcb9f0af90403a4de2150c125a148', + }, + html: { + href: 'https://bitbucket.org/atlassian/aui/commits/e5d1cde9069fcb9f0af90403a4de2150c125a148', + }, + diff: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/diff/e5d1cde9069fcb9f0af90403a4de2150c125a148', + }, + approve: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/commit/e5d1cde9069fcb9f0af90403a4de2150c125a148/approve', + }, + statuses: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/commit/e5d1cde9069fcb9f0af90403a4de2150c125a148/statuses', + }, + }, + author: { + raw: 'Marcin Konopka ', + type: 'author', + user: { + display_name: 'Marcin Konopka', + uuid: '{47cc24f4-2a05-4420-88fe-0417535a110a}', + links: { + self: { + href: 'https://api.bitbucket.org/2.0/users/%7B47cc24f4-2a05-4420-88fe-0417535a110a%7D', + }, + html: { + href: 'https://bitbucket.org/%7B47cc24f4-2a05-4420-88fe-0417535a110a%7D/', + }, + avatar: { + href: 'https://avatar-management--avatars.us-west-2.prod.public.atl-paas.net/initials/MK-1.png', + }, + }, + nickname: 'Marcin Konopka', + type: 'user', + account_id: '60113d2b47a9540069f4de03', + }, + }, + parents: [ + { + hash: '87f7fc92b00464ae47b13ef65c91884e4ac9be51', + type: 'commit', + links: { + self: { + href: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/commit/87f7fc92b00464ae47b13ef65c91884e4ac9be51', + }, + html: { + href: 'https://bitbucket.org/atlassian/aui/commits/87f7fc92b00464ae47b13ef65c91884e4ac9be51', + }, + }, + }, + ], + date: '2021-04-13T13:44:49+00:00', + message: 'wip\n', + type: 'commit', + }, + }, + ], + page: 1, + next: 'https://api.bitbucket.org/2.0/repositories/atlassian/aui/refs/branches?pagelen=1&page=2', + }; + const responseOfDefaultBranch = { + type: '', + links: { + self: { href: '', name: '' }, + html: { href: '', name: '' }, + avatar: { href: '', name: '' }, + pullrequests: { href: '', name: '' }, + commits: { href: '', name: '' }, + forks: { href: '', name: '' }, + watchers: { href: '', name: '' }, + downloads: { href: '', name: '' }, + clone: [{ href: '', name: '' }], + hooks: { href: '', name: '' }, + }, + uuid: '', + full_name: '', + is_private: true, + scm: 'git', + owner: { type: '' }, + name: '', + description: '', + created_on: '', + updated_on: '', + size: 2154, + language: '', + has_issues: true, + has_wiki: true, + fork_policy: 'allow_forks', + project: { type: '' }, + mainbranch: { type: '' }, + }; + const responseOfPullRequests = { + type: '', + links: { + self: { href: '', name: '' }, + html: { + href: 'https://bitbucket.org/workspace/repo/pull-requests/1', + name: '', + }, + commits: { href: '', name: '' }, + approve: { href: '', name: '' }, + diff: { href: '', name: '' }, + diffstat: { href: '', name: '' }, + comments: { href: '', name: '' }, + activity: { href: '', name: '' }, + merge: { href: '', name: '' }, + decline: { href: '', name: '' }, + }, + id: 108, + title: '', + rendered: { + title: { raw: '', markup: 'markdown', html: '' }, + description: { raw: '', markup: 'markdown', html: '' }, + reason: { raw: '', markup: 'markdown', html: '' }, + }, + summary: { raw: '', markup: 'markdown', html: '' }, + state: 'OPEN', + author: { type: '' }, + source: { + repository: { type: '' }, + branch: { + name: '', + merge_strategies: ['merge_commit'], + default_merge_strategy: '', + }, + commit: { hash: '' }, + }, + destination: { + repository: { type: '' }, + branch: { + name: '', + merge_strategies: ['merge_commit'], + default_merge_strategy: '', + }, + commit: { hash: '' }, + }, + merge_commit: { hash: '' }, + comment_count: 51, + task_count: 53, + close_source_branch: true, + closed_by: { type: '' }, + reason: '', + created_on: '', + updated_on: '', + reviewers: [{ type: '' }], + participants: [{ type: '' }], + }; + const handlers = [ + rest.get( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo/refs/branches', + (_, res, ctx) => { + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(responseOfBranches), + ); + }, + ), + rest.get( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo', + (_, res, ctx) => { + return res( + ctx.status(200), + ctx.set('Content-Type', 'application/json'), + ctx.json(responseOfDefaultBranch), + ); + }, + ), + rest.post( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo/pullrequests', + (_, res, ctx) => { + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json(responseOfPullRequests), + ); + }, + ), + ]; + + const server = setupServer(); + registerMswTestHooks(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: 'bitbucket.org?project=project&repo=repo', + }, + }), + ).rejects.toThrow(/missing workspace/); + + await expect( + action.handler({ + ...mockContext, + input: { + ...mockContext.input, + repoUrl: 'bitbucket.org?workspace=workspace&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?workspace=workspace&project=project&repo=repo', + }, + }), + ).rejects.toThrow(/No matching integration configuration/); + }); + + it('should call the correct APIs with basic auth', async () => { + expect.assertions(2); + server.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo/refs/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://api.bitbucket.org/2.0/repositories/workspace/repo/pullrequests', + (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: 'bitbucket.org?workspace=workspace&project=project&repo=repo', + }, + }); + }); + + it('should work if the token is provided through ctx.input', async () => { + expect.assertions(2); + const token = 'user-token'; + server.use( + rest.get( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo/refs/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://api.bitbucket.org/2.0/repositories/workspace/repo/pullrequests', + (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: 'bitbucket.org?workspace=workspace&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://bitbucket.org/workspace/repo/pull-requests/1', + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts new file mode 100644 index 0000000000..4d1f0e3a40 --- /dev/null +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts @@ -0,0 +1,464 @@ +/* + * 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 { InputError } from '@backstage/errors'; +import { ScmIntegrationRegistry } from '@backstage/integration'; +import { + createTemplateAction, + getRepoSourceDirectory, + commitAndPushBranch, + addFiles, + createBranch as createGitBranch, + cloneRepo, + parseRepoUrl, +} from '@backstage/plugin-scaffolder-node'; +import fetch, { RequestInit, Response } from 'node-fetch'; +import { Config } from '@backstage/config'; +import fs from 'fs-extra'; +import { getAuthorizationHeader } from './helpers'; +import { examples } from './bitbucketCloudPullRequest.examples'; + +const createPullRequest = async (opts: { + workspace: string; + repo: string; + title: string; + description?: string; + targetBranch: string; + sourceBranch: string; + authorization: string; + apiBaseUrl: string; +}) => { + const { + workspace, + repo, + title, + description, + targetBranch, + sourceBranch, + authorization, + apiBaseUrl, + } = opts; + + let response: Response; + const data: RequestInit = { + method: 'POST', + body: JSON.stringify({ + title: title, + summary: { + raw: description, + }, + state: 'OPEN', + source: { + branch: { + name: sourceBranch, + }, + }, + destination: { + branch: { + name: targetBranch, + }, + }, + }), + headers: { + Authorization: authorization, + 'Content-Type': 'application/json', + }, + }; + + try { + response = await fetch( + `${apiBaseUrl}/repositories/${workspace}/${repo}/pullrequests`, + 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.html.href; +}; + +const findBranches = async (opts: { + workspace: string; + repo: string; + branchName: string; + authorization: string; + apiBaseUrl: string; +}) => { + const { workspace, 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}/repositories/${workspace}/${repo}/refs/branches?q=${encodeURIComponent( + `name = "${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(); + + return r.values[0]; +}; +const createBranch = async (opts: { + workspace: string; + repo: string; + branchName: string; + authorization: string; + apiBaseUrl: string; + startBranch: string; +}) => { + const { + workspace, + repo, + branchName, + authorization, + apiBaseUrl, + startBranch, + } = opts; + + let response: Response; + const options: RequestInit = { + method: 'POST', + body: JSON.stringify({ + name: branchName, + target: { + hash: startBranch, + }, + }), + headers: { + Authorization: authorization, + 'Content-Type': 'application/json', + }, + }; + + try { + response = await fetch( + `${apiBaseUrl}/repositories/${workspace}/${repo}/refs/branches`, + options, + ); + } catch (e) { + throw new Error(`Unable to create branch, ${e}`); + } + + if (response.status !== 201) { + throw new Error( + `Unable to create branch, ${response.status} ${ + response.statusText + }, ${await response.text()}`, + ); + } + + return await response.json(); +}; +const getDefaultBranch = async (opts: { + workspace: string; + repo: string; + authorization: string; + apiBaseUrl: string; +}): Promise => { + const { workspace, repo, authorization, apiBaseUrl } = opts; + let response: Response; + + const options: RequestInit = { + method: 'GET', + headers: { + Authorization: authorization, + 'Content-Type': 'application/json', + }, + }; + + try { + response = await fetch( + `${apiBaseUrl}/repositories/${workspace}/${repo}`, + options, + ); + } catch (error) { + throw error; + } + + const { mainbranch } = await response.json(); + const defaultBranch = mainbranch.name; + if (!defaultBranch) { + throw new Error(`Could not fetch default branch for ${workspace}/${repo}`); + } + return defaultBranch; +}; +/** + * Creates a Bitbucket Cloud Pull Request action. + * @public + */ +export function createPublishBitbucketCloudPullRequestAction(options: { + integrations: ScmIntegrationRegistry; + config: Config; +}) { + const { integrations, config } = options; + + return createTemplateAction<{ + repoUrl: string; + title: string; + description?: string; + targetBranch?: string; + sourceBranch: string; + token?: string; + gitAuthorName?: string; + gitAuthorEmail?: string; + }>({ + id: 'publish:bitbucketCloud:pull-request', + examples, + schema: { + input: { + type: 'object', + required: ['repoUrl', 'title', 'sourceBranch'], + properties: { + repoUrl: { + title: 'Repository Location', + type: 'string', + }, + title: { + title: 'Pull Request title', + type: 'string', + description: 'The title 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: `Branch of repository to apply changes to. The default value is 'master'`, + }, + sourceBranch: { + title: 'Source Branch', + type: 'string', + description: 'Branch of repository to copy changes from', + }, + token: { + title: 'Authorization Token', + type: 'string', + description: + 'The token to use for authorization to BitBucket Cloud', + }, + gitAuthorName: { + title: 'Author Name', + type: 'string', + description: `Sets the author name for the commit. The default value is 'Scaffolder'`, + }, + gitAuthorEmail: { + title: 'Author Email', + type: 'string', + description: `Sets the author email for the commit.`, + }, + }, + }, + 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, + gitAuthorName, + gitAuthorEmail, + } = ctx.input; + + const { workspace, repo, host } = parseRepoUrl(repoUrl, integrations); + + if (!workspace) { + throw new InputError( + `Invalid URL provider was included in the repo URL to create ${ctx.input.repoUrl}, missing workspace`, + ); + } + + const integrationConfig = integrations.bitbucketCloud.byHost(host); + if (!integrationConfig) { + throw new InputError( + `No matching integration configuration for host ${host}, please check your integrations config`, + ); + } + + const authorization = getAuthorizationHeader( + ctx.input.token ? { token: ctx.input.token } : integrationConfig.config, + ); + + const apiBaseUrl = integrationConfig.config.apiBaseUrl; + + let finalTargetBranch = targetBranch; + if (!finalTargetBranch) { + finalTargetBranch = await getDefaultBranch({ + workspace, + repo, + authorization, + apiBaseUrl, + }); + } + + const sourceBranchRef = await findBranches({ + workspace, + repo, + branchName: sourceBranch, + authorization, + apiBaseUrl, + }); + + if (!sourceBranchRef) { + // create branch + ctx.logger.info( + `source branch not found -> creating branch named: ${sourceBranch}`, + ); + + await createBranch({ + workspace, + repo, + branchName: sourceBranch, + authorization, + apiBaseUrl, + startBranch: finalTargetBranch, + }); + + const remoteUrl = `https://${host}/${workspace}/${repo}.git`; + + let auth; + + if (ctx.input.token) { + auth = { + username: 'x-token-auth', + password: ctx.input.token, + }; + } else { + if ( + !integrationConfig.config.username || + !integrationConfig.config.appPassword + ) { + throw new Error( + 'Credentials for Bitbucket Cloud integration required for this action.', + ); + } + + auth = { + username: integrationConfig.config.username, + password: integrationConfig.config.appPassword, + }; + } + + const gitAuthorInfo = { + name: + gitAuthorName || + config.getOptionalString('scaffolder.defaultAuthor.name'), + email: + gitAuthorEmail || + config.getOptionalString('scaffolder.defaultAuthor.email'), + }; + + const tempDir = await ctx.createTemporaryDirectory(); + const sourceDir = getRepoSourceDirectory(ctx.workspacePath, undefined); + await cloneRepo({ + url: remoteUrl, + dir: tempDir, + auth, + logger: ctx.logger, + ref: sourceBranch, + }); + + await createGitBranch({ + dir: tempDir, + auth, + logger: ctx.logger, + ref: sourceBranch, + }); + + // copy files + fs.cpSync(sourceDir, tempDir, { + recursive: true, + filter: path => { + return !(path.indexOf('.git') > -1); + }, + }); + + await addFiles({ + dir: tempDir, + auth, + logger: ctx.logger, + filepath: '.', + }); + + await commitAndPushBranch({ + dir: tempDir, + auth, + logger: ctx.logger, + commitMessage: + description ?? + config.getOptionalString('scaffolder.defaultCommitMessage') ?? + '', + gitAuthorInfo, + branch: sourceBranch, + }); + } + + const pullRequestUrl = await createPullRequest({ + workspace, + repo, + title, + description, + targetBranch: finalTargetBranch, + sourceBranch, + authorization, + apiBaseUrl, + }); + + ctx.output('pullRequestUrl', pullRequestUrl); + }, + }); +} diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/index.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/index.ts index a9826a268a..c4030c0b52 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/index.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/index.ts @@ -15,3 +15,4 @@ */ export * from './bitbucketCloud'; export { createBitbucketPipelinesRunAction } from './bitbucketCloudPipelinesRun'; +export * from './bitbucketCloudPullRequest'; diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts index 5bf49f7cd9..c0f922ca08 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts @@ -24,6 +24,7 @@ import { import { createBitbucketPipelinesRunAction, createPublishBitbucketCloudAction, + createPublishBitbucketCloudPullRequestAction, } from './actions'; import { ScmIntegrations } from '@backstage/integration'; import { handleAutocompleteRequest } from './autocomplete/autocomplete'; @@ -48,6 +49,10 @@ export const bitbucketCloudModule = createBackendModule({ scaffolder.addActions( createPublishBitbucketCloudAction({ integrations, config }), createBitbucketPipelinesRunAction({ integrations }), + createPublishBitbucketCloudPullRequestAction({ + integrations, + config, + }), ); autocomplete.addAutocompleteProvider({ diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index dba867c96b..e2392150cc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -62,6 +62,7 @@ import { createPublishBitbucketAction } from '@backstage/plugin-scaffolder-backe import { createPublishBitbucketCloudAction, createBitbucketPipelinesRunAction, + createPublishBitbucketCloudPullRequestAction, } from '@backstage/plugin-scaffolder-backend-module-bitbucket-cloud'; import { @@ -197,6 +198,7 @@ export const createBuiltinActions = ( integrations, config, }), + createPublishBitbucketCloudPullRequestAction({ integrations, config }), createPublishBitbucketServerAction({ integrations, config,