Merge pull request #26465 from benjidotsh/scaffolder/bitbucket-cloud-pull-request-action-signed-off-2

feat(scaffolder): port Bitbucket Server PR action to Bitbucket Cloud
This commit is contained in:
Ben Lambert
2024-09-09 16:40:21 +02:00
committed by GitHub
10 changed files with 1472 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
---
'@backstage/plugin-scaffolder-backend': minor
'@backstage/plugin-scaffolder-backend-module-bitbucket-cloud': patch
---
Added scaffolder action publish:bitbucketCloud:pull-request
@@ -9,3 +9,4 @@ _This plugin was created through the Backstage CLI_
- `publish:bitbucketCloud`
- `bitbucket:pipelines:run`
- `publish:bitbucketCloud:pull-request`
@@ -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
>;
```
@@ -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 <mkonopka@atlassian.com>',
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: '<string>',
links: {
self: { href: '<string>', name: '<string>' },
html: {
href: 'https://bitbucket.org/workspace/repo/pull-requests/1',
name: '<string>',
},
commits: { href: '<string>', name: '<string>' },
approve: { href: '<string>', name: '<string>' },
diff: { href: '<string>', name: '<string>' },
diffstat: { href: '<string>', name: '<string>' },
comments: { href: '<string>', name: '<string>' },
activity: { href: '<string>', name: '<string>' },
merge: { href: '<string>', name: '<string>' },
decline: { href: '<string>', name: '<string>' },
},
id: 108,
title: '<string>',
rendered: {
title: { raw: '<string>', markup: 'markdown', html: '<string>' },
description: { raw: '<string>', markup: 'markdown', html: '<string>' },
reason: { raw: '<string>', markup: 'markdown', html: '<string>' },
},
summary: { raw: '<string>', markup: 'markdown', html: '<string>' },
state: 'OPEN',
author: { type: '<string>' },
source: {
repository: { type: '<string>' },
branch: {
name: '<string>',
merge_strategies: ['merge_commit'],
default_merge_strategy: '<string>',
},
commit: { hash: '<string>' },
},
destination: {
repository: { type: '<string>' },
branch: {
name: '<string>',
merge_strategies: ['merge_commit'],
default_merge_strategy: '<string>',
},
commit: { hash: '<string>' },
},
merge_commit: { hash: '<string>' },
comment_count: 51,
task_count: 53,
close_source_branch: true,
closed_by: { type: '<string>' },
reason: '<string>',
created_on: '<string>',
updated_on: '<string>',
reviewers: [{ type: '<string>' }],
participants: [{ type: '<string>' }],
};
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,
},
});
});
});
@@ -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',
},
},
],
}),
},
];
@@ -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 <mkonopka@atlassian.com>',
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: '<string>',
links: {
self: { href: '<string>', name: '<string>' },
html: { href: '<string>', name: '<string>' },
avatar: { href: '<string>', name: '<string>' },
pullrequests: { href: '<string>', name: '<string>' },
commits: { href: '<string>', name: '<string>' },
forks: { href: '<string>', name: '<string>' },
watchers: { href: '<string>', name: '<string>' },
downloads: { href: '<string>', name: '<string>' },
clone: [{ href: '<string>', name: '<string>' }],
hooks: { href: '<string>', name: '<string>' },
},
uuid: '<string>',
full_name: '<string>',
is_private: true,
scm: 'git',
owner: { type: '<string>' },
name: '<string>',
description: '<string>',
created_on: '<string>',
updated_on: '<string>',
size: 2154,
language: '<string>',
has_issues: true,
has_wiki: true,
fork_policy: 'allow_forks',
project: { type: '<string>' },
mainbranch: { type: '<string>' },
};
const responseOfPullRequests = {
type: '<string>',
links: {
self: { href: '<string>', name: '<string>' },
html: {
href: 'https://bitbucket.org/workspace/repo/pull-requests/1',
name: '<string>',
},
commits: { href: '<string>', name: '<string>' },
approve: { href: '<string>', name: '<string>' },
diff: { href: '<string>', name: '<string>' },
diffstat: { href: '<string>', name: '<string>' },
comments: { href: '<string>', name: '<string>' },
activity: { href: '<string>', name: '<string>' },
merge: { href: '<string>', name: '<string>' },
decline: { href: '<string>', name: '<string>' },
},
id: 108,
title: '<string>',
rendered: {
title: { raw: '<string>', markup: 'markdown', html: '<string>' },
description: { raw: '<string>', markup: 'markdown', html: '<string>' },
reason: { raw: '<string>', markup: 'markdown', html: '<string>' },
},
summary: { raw: '<string>', markup: 'markdown', html: '<string>' },
state: 'OPEN',
author: { type: '<string>' },
source: {
repository: { type: '<string>' },
branch: {
name: '<string>',
merge_strategies: ['merge_commit'],
default_merge_strategy: '<string>',
},
commit: { hash: '<string>' },
},
destination: {
repository: { type: '<string>' },
branch: {
name: '<string>',
merge_strategies: ['merge_commit'],
default_merge_strategy: '<string>',
},
commit: { hash: '<string>' },
},
merge_commit: { hash: '<string>' },
comment_count: 51,
task_count: 53,
close_source_branch: true,
closed_by: { type: '<string>' },
reason: '<string>',
created_on: '<string>',
updated_on: '<string>',
reviewers: [{ type: '<string>' }],
participants: [{ type: '<string>' }],
};
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',
);
});
});
@@ -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<string> => {
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);
},
});
}
@@ -15,3 +15,4 @@
*/
export * from './bitbucketCloud';
export { createBitbucketPipelinesRunAction } from './bitbucketCloudPipelinesRun';
export * from './bitbucketCloudPullRequest';
@@ -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({
@@ -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,