diff --git a/.changeset/chilled-rocks-rule.md b/.changeset/chilled-rocks-rule.md new file mode 100644 index 0000000000..bb2aad2a47 --- /dev/null +++ b/.changeset/chilled-rocks-rule.md @@ -0,0 +1,5 @@ +--- +'@backstage/plugin-scaffolder-backend-module-bitbucket-cloud': patch +--- + +Added bitbucketCloud:branchRestriction:create to allow users to create bitbucket cloud branch restrictions in templates diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.examples.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.examples.test.ts new file mode 100644 index 0000000000..4897d6acbf --- /dev/null +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.examples.test.ts @@ -0,0 +1,162 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { createBitbucketCloudBranchRestrictionAction } from './bitbucketCloudBranchRestriction'; +import { ScmIntegrations } from '@backstage/integration'; +import { ConfigReader } from '@backstage/config'; +import yaml from 'yaml'; +import { examples } from './bitbucketCloudBranchRestriction.examples'; +import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; + +describe('bitbucketCloud:branchRestriction:create', () => { + const config = new ConfigReader({ + integrations: { + bitbucketCloud: [ + { + username: 'x-token-auth', + appPassword: 'your-default-auth-token', + }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + const action = createBitbucketCloudBranchRestrictionAction({ integrations }); + const mockContext = createMockActionContext({ + input: { + repoUrl: + 'bitbucket.org?workspace=workspace&project=project&repo=repo&project=project', + }, + }); + + beforeEach(() => { + global.fetch = jest.fn().mockImplementation(() => + Promise.resolve({ + status: 201, + json: () => + Promise.resolve({ + status: 201, + }), + }), + ); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should restrict push to the main branch, except for the defined uuids, and the admins group', async () => { + const input = yaml.parse(examples[0].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { input }); + await action.handler(ctx); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo/branch-restrictions', + { + body: JSON.stringify({ + branch_match_kind: 'branching_model', + users: [{ uuid: '{a-b-c-d}' }, { uuid: '{e-f-g-h}' }], + groups: [{ slug: 'admins' }], + kind: 'push', + branch_type: 'development', + }), + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: + 'Basic eC10b2tlbi1hdXRoOnlvdXItZGVmYXVsdC1hdXRoLXRva2Vu', + 'Content-Type': 'application/json', + }, + }, + ); + }); + + it('should restrict push to the main branch, except for the admins group', async () => { + const input = yaml.parse(examples[1].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { input }); + await action.handler(ctx); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo/branch-restrictions', + { + body: JSON.stringify({ + branch_match_kind: 'branching_model', + users: [], + groups: [{ slug: 'admins' }], + kind: 'push', + branch_type: 'development', + }), + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: + 'Basic eC10b2tlbi1hdXRoOnlvdXItZGVmYXVsdC1hdXRoLXRva2Vu', + 'Content-Type': 'application/json', + }, + }, + ); + }); + + it('should require passing builds to merge to branches matching a pattern test-feature/*', async () => { + const input = yaml.parse(examples[2].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { input }); + await action.handler(ctx); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo/branch-restrictions', + { + body: JSON.stringify({ + branch_match_kind: 'glob', + kind: 'require_passing_builds_to_merge', + value: 1, + pattern: 'test-feature/*', + }), + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: + 'Basic eC10b2tlbi1hdXRoOnlvdXItZGVmYXVsdC1hdXRoLXRva2Vu', + 'Content-Type': 'application/json', + }, + }, + ); + }); + + it('should require approvals to merge to branches matching a pattern test-feature/*', async () => { + const input = yaml.parse(examples[3].example).steps[0].input; + const ctx = Object.assign({}, mockContext, { input }); + await action.handler(ctx); + + expect(global.fetch).toHaveBeenCalledWith( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo/branch-restrictions', + { + body: JSON.stringify({ + branch_match_kind: 'glob', + kind: 'require_approvals_to_merge', + value: 1, + pattern: 'test-feature/*', + }), + method: 'POST', + headers: { + Accept: 'application/json', + Authorization: + 'Basic eC10b2tlbi1hdXRoOnlvdXItZGVmYXVsdC1hdXRoLXRva2Vu', + 'Content-Type': 'application/json', + }, + }, + ); + }); +}); diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.examples.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.examples.ts new file mode 100644 index 0000000000..bfe24d1e70 --- /dev/null +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.examples.ts @@ -0,0 +1,115 @@ +/* + * Copyright 2023 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { TemplateExample } from '@backstage/plugin-scaffolder-node'; +import yaml from 'yaml'; + +export const examples: TemplateExample[] = [ + { + description: + 'restrict push to the main branch, except for alice, bob, and the admins group', + example: yaml.stringify({ + steps: [ + { + id: 'createBranchRestriction', + action: 'bitbucketCloud:branchRestriction:create', + name: 'Create Bitbucket Cloud branch restriction', + input: { + repoUrl: + 'bitbucket.org?repo=repo&workspace=workspace&project=project', + kind: 'push', + users: [ + { + uuid: '{a-b-c-d}', + }, + { + uuid: '{e-f-g-h}', + }, + ], + groups: [ + { + slug: 'admins', + }, + ], + }, + }, + ], + }), + }, + { + description: + 'restrict push to the main branch, except for the admins group', + example: yaml.stringify({ + steps: [ + { + id: 'restrictPushToFeatureBranches', + action: 'bitbucketCloud:branchRestriction:create', + name: 'Create Bitbucket Cloud branch restriction by branch type', + input: { + repoUrl: + 'bitbucket.org?repo=repo&workspace=workspace&project=project', + kind: 'push', + groups: [ + { + slug: 'admins', + }, + ], + }, + }, + ], + }), + }, + { + description: + 'require passing builds to merge to branches matching a pattern test-feature/*', + example: yaml.stringify({ + steps: [ + { + id: 'requirePassingBuildsToMergeByPattern', + action: 'bitbucketCloud:branchRestriction:create', + name: 'Create Bitbucket Cloud require passing build to merge to branches matching a pattern', + input: { + repoUrl: + 'bitbucket.org?repo=repo&workspace=workspace&project=project', + kind: 'require_passing_builds_to_merge', + branchMatchKind: 'glob', + pattern: 'test-feature/*', + }, + }, + ], + }), + }, + { + description: + 'require approvals to merge to branches matching a pattern test-feature/*', + example: yaml.stringify({ + steps: [ + { + id: 'requireApprovalsToMergeByPattern', + action: 'bitbucketCloud:branchRestriction:create', + name: 'Create Bitbucket Cloud require approvals to merge branch restriction', + input: { + repoUrl: + 'bitbucket.org?repo=repo&workspace=workspace&project=project', + kind: 'require_approvals_to_merge', + branchMatchKind: 'glob', + pattern: 'test-feature/*', + }, + }, + ], + }), + }, +]; diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.test.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.test.ts new file mode 100644 index 0000000000..f16a7f6f70 --- /dev/null +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.test.ts @@ -0,0 +1,100 @@ +/* + * 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 { createBitbucketCloudBranchRestrictionAction } from './bitbucketCloudBranchRestriction'; +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('bitbucketCloud:branchRestriction:create', () => { + const config = new ConfigReader({ + integrations: { + bitbucketCloud: [ + { + username: 'u', + appPassword: 'p', + }, + ], + }, + }); + + const integrations = ScmIntegrations.fromConfig(config); + const action = createBitbucketCloudBranchRestrictionAction({ integrations }); + const mockContext = createMockActionContext({ + input: { + repoUrl: 'bitbucket.org?workspace=workspace&project=project&repo=repo', + kind: 'push', + }, + }); + const server = setupServer(); + registerMswTestHooks(server); + + it('should work if the token is provided through ctx.input', async () => { + expect.assertions(2); + const token = 'user-token'; + server.use( + rest.post( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo/branch-restrictions', + (req, res, ctx) => { + expect(req.headers.get('Authorization')).toBe(`Bearer ${token}`); + req.json().then(data => { + expect(data).toEqual({ + branch_match_kind: 'branching_model', + branch_type: 'development', + users: [], + groups: [], + kind: 'push', + }); + }); + return res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json({}), + ); + }, + ), + ); + await action.handler({ + ...mockContext, + input: { + ...mockContext.input, + token: token, + }, + }); + }); + + it('should return correct outputs', async () => { + server.use( + rest.post( + 'https://api.bitbucket.org/2.0/repositories/workspace/repo/branch-restrictions', + (_, res, ctx) => + res( + ctx.status(201), + ctx.set('Content-Type', 'application/json'), + ctx.json({}), + ), + ), + ); + + await action.handler(mockContext); + + expect(mockContext.output).toHaveBeenCalledWith('statusCode', 201); + expect(mockContext.output).toHaveBeenCalledWith('json', '{}'); + }); +}); diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.ts new file mode 100644 index 0000000000..d1628bcf12 --- /dev/null +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.ts @@ -0,0 +1,209 @@ +/* + * Copyright 2025 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 { ScmIntegrationRegistry } from '@backstage/integration'; +import { + createTemplateAction, + parseRepoUrl, +} from '@backstage/plugin-scaffolder-node'; +import { InputError } from '@backstage/errors'; +import { getAuthorizationHeader } from './helpers'; +import * as inputProps from './inputProperties'; +import { examples } from './bitbucketCloudBranchRestriction.examples'; + +const createBitbucketCloudBranchRestriction = async (opts: { + workspace: string; + repo: string; + kind: string; + branchMatchKind?: string; + branchType?: string; + pattern?: string; + value?: number; + users?: Array; + groups?: Array; + authorization: string; + apiBaseUrl: string; +}) => { + const { + workspace, + repo, + kind, + branchMatchKind, + branchType, + pattern, + value, + users, + groups, + authorization, + apiBaseUrl, + } = opts; + + const body = new Map(); + body.set('branch_match_kind', branchMatchKind || 'branching_model'); + if (kind in ['push', 'restrict_merges']) { + body.set('users', kind in ['push', 'restrict_merges'] ? users || [] : null); + body.set( + 'groups', + kind in ['push', 'restrict_merges'] ? groups || [] : null, + ); + } + body.set('kind', kind); + if ( + kind === 'require_approvals_to_merge' || + kind === 'require_default_reviewer_approvals_to_merge' || + kind === 'require_commits_behind' || + kind === 'require_passing_builds_to_merge' + ) { + body.set('value', value || 1); + } + if (branchMatchKind === 'glob') { + body.set('pattern', pattern || ''); + } + if (branchMatchKind === 'branching_model') { + body.set('branch_type', branchType || 'development'); + } + + const options: RequestInit = { + method: 'POST', + body: JSON.stringify(Object.fromEntries(body)), + headers: { + Authorization: authorization, + 'Content-Type': 'application/json', + Accept: 'application/json', + }, + }; + + let response: Response; + try { + response = await fetch( + `${apiBaseUrl}/repositories/${workspace}/${repo}/branch-restrictions`, + options, + ); + } catch (e) { + throw new Error( + `Unable to set branch restrictions for the repository, ${e}`, + ); + } + + if (response.status !== 201) { + throw new Error( + `Unable to set branch restrictions for the repository, ${ + response.status + } ${response.statusText}, ${await response.text()}`, + ); + } + return response; +}; + +export function createBitbucketCloudBranchRestrictionAction(options: { + integrations: ScmIntegrationRegistry; +}) { + const { integrations } = options; + return createTemplateAction<{ + repoUrl: string; + kind: string; + branchMatchKind?: string; + branchType?: string; + pattern?: string; + value?: number; + users?: Array; + groups?: Array; + token?: string; + }>({ + id: 'bitbucketCloud:branchRestriction:create', + examples, + description: + 'Creates branch restrictions for a Bitbucket Cloud repository.', + schema: { + input: { + type: 'object', + required: ['repoUrl', 'kind'], + properties: { + repoUrl: inputProps.repoUrl, + kind: inputProps.restriction.kind, + branchMatchKind: inputProps.restriction.branchMatchKind, + branchType: inputProps.restriction.branchType, + pattern: inputProps.restriction.pattern, + value: inputProps.restriction.value, + users: inputProps.restriction.users, + groups: inputProps.restriction.groups, + token: inputProps.token, + }, + }, + output: { + type: 'object', + properties: { + json: { + title: 'The response from bitbucket cloud', + type: 'string', + }, + statusCode: { + title: 'The status code of the response', + type: 'number', + }, + }, + }, + }, + async handler(ctx) { + const { + repoUrl, + kind, + branchMatchKind = 'branching_model', + branchType = 'development', + pattern, + value, + users, + groups, + } = 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; + + const response = await createBitbucketCloudBranchRestriction({ + workspace: workspace, + repo, + kind: kind, + branchMatchKind: branchMatchKind, + branchType: branchType, + pattern: pattern, + value: value, + users: users, + groups: groups, + authorization, + apiBaseUrl, + }); + ctx.output('statusCode', response.status); + ctx.output('json', JSON.stringify(await response.json())); + }, + }); +} 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 c4030c0b52..986df299d2 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/index.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/index.ts @@ -16,3 +16,4 @@ export * from './bitbucketCloud'; export { createBitbucketPipelinesRunAction } from './bitbucketCloudPipelinesRun'; export * from './bitbucketCloudPullRequest'; +export * from './bitbucketCloudBranchRestriction'; diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/inputProperties.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/inputProperties.ts index d37426e982..cff1457463 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/inputProperties.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/inputProperties.ts @@ -14,6 +14,12 @@ * limitations under the License. */ +const repoUrl = { + title: 'Repository Location', + description: `Accepts the format 'bitbucket.org?repo=reponame&workspace=workspace&project=project' where 'reponame' is the new repository name`, + type: 'string', +}; + const workspace = { title: 'Workspace', description: `The workspace name`, @@ -152,4 +158,95 @@ const pipelinesRunBody = { }, }; +const restriction = { + kind: { + title: 'kind', + description: 'The kind of restriction.', + type: 'string', + enum: [ + 'push', + 'force', + 'delete', + 'restrict_merges', + 'require_tasks_to_be_completed', + 'require_approvals_to_merge', + 'require_default_reviewer_approvals_to_merge', + 'require_no_changes_requested', + 'require_passing_builds_to_merge', + 'require_commits_behind', + 'reset_pullrequest_approvals_on_change', + 'smart_reset_pullrequest_approvals', + 'reset_pullrequest_changes_requested_on_change', + 'require_all_dependencies_merged', + 'enforce_merge_checks', + 'allow_auto_merge_when_builds_pass', + ], + }, + branchMatchKind: { + title: 'branch_match_kind', + description: 'The branch match kind.', + type: 'string', + enum: ['glob', 'branching_model'], + }, + branchType: { + title: 'branch_type', + description: + 'The branch type. When branchMatchKind is set to branching_model, this field is required.', + type: 'string', + enum: [ + 'feature', + 'bugfix', + 'release', + 'hotfix', + 'development', + 'production', + ], + }, + pattern: { + title: 'pattern', + description: + 'The pattern to match branches against. This field is required when branchMatchKind is set to glob.', + type: 'string', + }, + value: { + title: 'value', + description: + 'The value of the restriction. This field is required when kind is one of require_approvals_to_merge / require_default_reviewer_approvals_to_merge / require_passing_builds_to_merge / require_commits_behind.', + type: 'number', + }, + users: { + title: 'users', + description: + 'Names of users that can bypass the push / restrict_merges restriction kind. For any other kind, this field will be ignored.', + type: 'array', + items: { + type: 'object', + properties: { + uuid: { + title: 'uuid', + description: 'The UUID of the user in the format "{a-b-c-d}".', + type: 'string', + }, + }, + }, + }, + groups: { + title: 'groups', + description: + 'Names of groups that can bypass the push / restrict_merges restriction kind. For any other kind, this field will be ignored.', + type: 'array', + items: { + type: 'object', + properties: { + slug: { + title: 'slug', + description: 'The name of the group.', + type: 'string', + }, + }, + }, + }, +}; + export { workspace, repo_slug, pipelinesRunBody, token }; +export { repoUrl, restriction }; diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts index c0f922ca08..3a9871f39c 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts @@ -25,6 +25,7 @@ import { createBitbucketPipelinesRunAction, createPublishBitbucketCloudAction, createPublishBitbucketCloudPullRequestAction, + createBitbucketCloudBranchRestrictionAction, } from './actions'; import { ScmIntegrations } from '@backstage/integration'; import { handleAutocompleteRequest } from './autocomplete/autocomplete'; @@ -53,6 +54,9 @@ export const bitbucketCloudModule = createBackendModule({ integrations, config, }), + createBitbucketCloudBranchRestrictionAction({ + integrations, + }), ); autocomplete.addAutocompleteProvider({