diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/package.json b/plugins/scaffolder-backend-module-bitbucket-cloud/package.json index 9703ab0c86..785d26e8a0 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/package.json +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/package.json @@ -48,6 +48,7 @@ "@backstage/integration": "workspace:^", "@backstage/plugin-bitbucket-cloud-common": "workspace:^", "@backstage/plugin-scaffolder-node": "workspace:^", + "bitbucket": "^2.12.0", "fs-extra": "^11.2.0", "yaml": "^2.0.0" }, 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 index 0e568e3d22..7816580a01 100644 --- 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 @@ -1,5 +1,5 @@ /* - * Copyright 2023 The Backstage Authors + * 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. @@ -15,22 +15,24 @@ */ 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 yaml from 'yaml'; import { examples } from './bitbucketCloudBranchRestriction.examples'; import { createMockActionContext } from '@backstage/plugin-scaffolder-node-test-utils'; +import { Bitbucket } from 'bitbucket'; + +jest.mock('bitbucket', () => ({ + Bitbucket: jest.fn(), +})); describe('bitbucketCloud:branchRestriction:create', () => { const config = new ConfigReader({ integrations: { bitbucketCloud: [ { - username: 'x-token-auth', - appPassword: 'your-default-auth-token', + username: 'u', + appPassword: 'p', }, ], }, @@ -45,94 +47,99 @@ describe('bitbucketCloud:branchRestriction:create', () => { kind: 'push', }, }); - const server = setupServer(); - registerMswTestHooks(server); - it('should restrict push to the main branch, except for the defined uuids, and the admins group', async () => { - server.use( - rest.post( - 'https://api.bitbucket.org/2.0/repositories/workspace/repo/branch-restrictions', - (req, res, ctx) => { - req.json().then(data => { - expect(data).toEqual({ - branch_match_kind: 'branching_model', - branch_type: 'development', - users: [{ uuid: '{a-b-c-d}' }, { uuid: '{e-f-g-h}' }], - groups: [{ slug: 'admins' }], - kind: 'push', - }); - }); - return res( - ctx.status(201), - ctx.set('Content-Type', 'application/json'), - ctx.json({}), - ); - }, - ), - ); - const input = yaml.parse(examples[0].example).steps[0].input; + const mockBranchRestrictionsApi = { + branchrestrictions: { + create: jest.fn().mockResolvedValue({ status: 201, data: {} }), + }, + }; + (Bitbucket as unknown as jest.Mock).mockImplementation( + () => mockBranchRestrictionsApi, + ); + + beforeEach(() => { + jest.clearAllMocks(); + }); + + afterEach(() => + mockBranchRestrictionsApi.branchrestrictions.create.mockClear(), + ); + + it(`should restrict push to the main branch, except for two user ids, and the admins group`, async () => { await action.handler({ ...mockContext, input: { ...mockContext.input, - ...input, + ...yaml.parse(examples[0].example).steps[0].input, }, }); + + expect( + mockBranchRestrictionsApi.branchrestrictions.create, + ).toHaveBeenCalledWith({ + _body: { + branch_match_kind: 'branching_model', + branch_type: 'development', + users: [ + { + uuid: '{a-b-c-d}', + type: 'user', + }, + { + uuid: '{e-f-g-h}', + type: 'user', + }, + ], + groups: [ + { + slug: 'admins', + type: 'group', + }, + ], + kind: 'push', + value: null, + pattern: undefined, + type: 'branchrestriction', + }, + repo_slug: 'repo', + workspace: 'workspace', + }); }); it('should restrict push to the main branch, except for the admins group', async () => { - server.use( - rest.post( - 'https://api.bitbucket.org/2.0/repositories/workspace/repo/branch-restrictions', - (req, res, ctx) => { - req.json().then(data => { - expect(data).toEqual({ - branch_match_kind: 'branching_model', - users: [], - groups: [{ slug: 'admins' }], - kind: 'push', - branch_type: 'development', - }); - }); - return res( - ctx.status(201), - ctx.set('Content-Type', 'application/json'), - ctx.json({}), - ); - }, - ), - ); - const input = yaml.parse(examples[1].example).steps[0].input; await action.handler({ ...mockContext, input: { ...mockContext.input, - ...input, + ...yaml.parse(examples[1].example).steps[0].input, }, }); + + expect( + mockBranchRestrictionsApi.branchrestrictions.create, + ).toHaveBeenCalledWith({ + _body: { + branch_match_kind: 'branching_model', + branch_type: 'development', + users: [], + groups: [ + { + slug: 'admins', + type: 'group', + }, + ], + kind: 'push', + value: null, + pattern: undefined, + type: 'branchrestriction', + }, + repo_slug: 'repo', + workspace: 'workspace', + }); }); + // it('should require passing builds to merge to branches matching a pattern test-feature/*', async () => { - server.use( - rest.post( - 'https://api.bitbucket.org/2.0/repositories/workspace/repo/branch-restrictions', - (req, res, ctx) => { - req.json().then(data => { - expect(data).toEqual({ - branch_match_kind: 'glob', - kind: 'require_passing_builds_to_merge', - value: 1, - pattern: 'test-feature/*', - }); - }); - return res( - ctx.status(201), - ctx.set('Content-Type', 'application/json'), - ctx.json({}), - ); - }, - ), - ); const input = yaml.parse(examples[2].example).steps[0].input; await action.handler({ ...mockContext, @@ -141,29 +148,27 @@ describe('bitbucketCloud:branchRestriction:create', () => { ...input, }, }); + + expect( + mockBranchRestrictionsApi.branchrestrictions.create, + ).toHaveBeenCalledWith({ + _body: { + branch_match_kind: 'glob', + branch_type: undefined, + users: [], + groups: [], + kind: 'require_passing_builds_to_merge', + value: 1, + pattern: 'test-feature/*', + type: 'branchrestriction', + }, + repo_slug: 'repo', + workspace: 'workspace', + }); }); + // it('should require approvals to merge to branches matching a pattern test-feature/*', async () => { - server.use( - rest.post( - 'https://api.bitbucket.org/2.0/repositories/workspace/repo/branch-restrictions', - (req, res, ctx) => { - req.json().then(data => { - expect(data).toEqual({ - branch_match_kind: 'glob', - kind: 'require_approvals_to_merge', - value: 1, - pattern: 'test-feature/*', - }); - }); - return res( - ctx.status(201), - ctx.set('Content-Type', 'application/json'), - ctx.json({}), - ); - }, - ), - ); const input = yaml.parse(examples[3].example).steps[0].input; await action.handler({ ...mockContext, @@ -172,5 +177,22 @@ describe('bitbucketCloud:branchRestriction:create', () => { ...input, }, }); + + expect( + mockBranchRestrictionsApi.branchrestrictions.create, + ).toHaveBeenCalledWith({ + _body: { + branch_match_kind: 'glob', + branch_type: undefined, + users: [], + groups: [], + kind: 'require_approvals_to_merge', + value: 1, + pattern: 'test-feature/*', + type: 'branchrestriction', + }, + repo_slug: 'repo', + workspace: 'workspace', + }); }); }); 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 index f16a7f6f70..55ee430091 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.test.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.test.ts @@ -1,5 +1,5 @@ /* - * Copyright 2021 The Backstage Authors + * 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. @@ -14,87 +14,33 @@ * 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'; +import { getBitbucketClient } from './helpers'; +import { Bitbucket } from 'bitbucket'; + +jest.mock('bitbucket', () => ({ + Bitbucket: jest.fn(), +})); 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('getBitbucketClient should return the correct headers with username and password', () => { + expect.assertions(1); + const username = 'username'; + const password = 'password'; + getBitbucketClient({ username: username, appPassword: password }); + expect(Bitbucket).toHaveBeenCalledWith({ + auth: { + username: username, + password: password, }, }); }); - 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({}), - ), - ), - ); + it('getBitbucketClient should throw if only one of username or password is provided', () => { + expect.assertions(2); + const username = 'username'; + const password = 'password'; - await action.handler(mockContext); - - expect(mockContext.output).toHaveBeenCalledWith('statusCode', 201); - expect(mockContext.output).toHaveBeenCalledWith('json', '{}'); + expect(() => getBitbucketClient({ username })).toThrow(Error); + expect(() => getBitbucketClient({ appPassword: password })).toThrow(Error); }); }); diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.ts index fc997fd7b8..d63ccf1b7c 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.ts @@ -19,7 +19,7 @@ import { parseRepoUrl, } from '@backstage/plugin-scaffolder-node'; import { InputError } from '@backstage/errors'; -import { getAuthorizationHeader } from './helpers'; +import { getBitbucketClient } from './helpers'; import * as inputProps from './inputProperties'; import { examples } from './bitbucketCloudBranchRestriction.examples'; @@ -31,10 +31,13 @@ const createBitbucketCloudBranchRestriction = async (opts: { branchType?: string; pattern?: string; value?: number; - users?: Array; - groups?: Array; - authorization: string; - apiBaseUrl: string; + users?: { uuid: string; type: string }[]; + groups?: { slug: string; type: string }[]; + authorization: { + token?: string; + username?: string; + appPassword?: string; + }; }) => { const { workspace, @@ -47,64 +50,24 @@ const createBitbucketCloudBranchRestriction = async (opts: { 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', + const bitbucket = getBitbucketClient(authorization); + return await bitbucket.branchrestrictions.create({ + _body: { + groups: groups, + users: users, + branch_match_kind: branchMatchKind, + kind: kind, + type: 'branchrestriction', + value: kind === 'push' ? null : value, + pattern: branchMatchKind === 'glob' ? pattern : undefined, + branch_type: + branchMatchKind === 'branching_model' ? branchType : undefined, }, - }; - - 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; + repo_slug: repo, + workspace: workspace, + }); }; /** @@ -122,8 +85,8 @@ export function createBitbucketCloudBranchRestrictionAction(options: { branchType?: string; pattern?: string; value?: number; - users?: Array; - groups?: Array; + users?: { uuid: string }[]; + groups?: { slug: string }[]; token?: string; }>({ id: 'bitbucketCloud:branchRestriction:create', @@ -166,10 +129,11 @@ export function createBitbucketCloudBranchRestrictionAction(options: { kind, branchMatchKind = 'branching_model', branchType = 'development', - pattern, - value, - users, - groups, + pattern = '', + value = 1, + users = [], + groups = [], + token = '', } = ctx.input; const { workspace, repo, host } = parseRepoUrl(repoUrl, integrations); @@ -187,11 +151,7 @@ export function createBitbucketCloudBranchRestrictionAction(options: { ); } - const authorization = getAuthorizationHeader( - ctx.input.token ? { token: ctx.input.token } : integrationConfig.config, - ); - - const apiBaseUrl = integrationConfig.config.apiBaseUrl; + const authorization = token ? { token: token } : integrationConfig.config; const response = await createBitbucketCloudBranchRestriction({ workspace: workspace, @@ -201,13 +161,22 @@ export function createBitbucketCloudBranchRestrictionAction(options: { branchType: branchType, pattern: pattern, value: value, - users: users, - groups: groups, + users: users.map(user => ({ uuid: user.uuid, type: 'user' })), + groups: groups.map(group => ({ slug: group.slug, type: 'group' })), authorization, - apiBaseUrl, }); + if (response.data.errors) { + ctx.logger.error( + `Error from Bitbucket Cloud Branch Restrictions: ${JSON.stringify( + response.data.errors, + )}`, + ); + } + ctx.logger.info( + `Response from Bitbucket Cloud: ${JSON.stringify(response)}`, + ); ctx.output('statusCode', response.status); - ctx.output('json', JSON.stringify(await response.json())); + ctx.output('json', JSON.stringify(response)); }, }); } diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts index efa6fd64f4..1abffea393 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts @@ -14,6 +14,32 @@ * limitations under the License. */ +import { Bitbucket } from 'bitbucket'; + +export const getBitbucketClient = (config: { + token?: string; + username?: string; + appPassword?: string; +}) => { + if (config.username && config.appPassword) { + return new Bitbucket({ + auth: { + username: config.username, + password: config.appPassword, + }, + }); + } else if (config.token) { + return new Bitbucket({ + auth: { + token: config.token, + }, + }); + } + throw new Error( + `Authorization has not been provided for Bitbucket Cloud. Please add either username + appPassword to the Integrations config or a user login auth token`, + ); +}; + export const getAuthorizationHeader = (config: { username?: string; appPassword?: string; diff --git a/plugins/scaffolder-backend/sample-templates/azure-devops-demo/python-sample/catalog-info.yaml b/plugins/scaffolder-backend/sample-templates/azure-devops-demo/python-sample/catalog-info.yaml new file mode 100644 index 0000000000..a8c7e1fd7f --- /dev/null +++ b/plugins/scaffolder-backend/sample-templates/azure-devops-demo/python-sample/catalog-info.yaml @@ -0,0 +1,107 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: ${{ values.name }} + annotations: + backstage.io/techdocs-ref: dir:. +spec: + type: service + owner: ${{ values.owner }} + lifecycle: experimental + providesApis: + - ${{ values.name }}-hello-api + - ${{ values.name }}-hello-name-api + - ${{ values.name }}-isalive-api +--- +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: ${{ values.name }}-hello-api + description: A simple hello world API +spec: + type: openapi + lifecycle: experimental + owner: ${{ values.owner }} + system: devops-infra + definition: | + openapi: "3.0.0" + info: + version: 1.0.0 + title: Hello API + license: + name: MIT + servers: + - url: http://localhost:5000 + paths: + /v1/hello: + get: + responses: + '200': + description: Returns a hello world message + summary: Returns a hello world message + ... +--- +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: ${{ values.name }}-hello-name-api + description: A simple hello world API +spec: + type: openapi + lifecycle: experimental + owner: ${{ values.owner }} + system: devops-infra + definition: | + openapi: "3.0.0" + info: + version: 1.0.0 + title: Hello Name API + license: + name: MIT + servers: + - url: http://localhost:5000 + paths: + /v1/hello/{name}: + parameters: + - name: name + in: path + required: true + description: The name to say hello to + schema: + type: string + get: + responses: + '200': + description: Returns a hello world message + summary: Returns a hello world message + ... +--- +apiVersion: backstage.io/v1alpha1 +kind: API +metadata: + name: ${{ values.name }}-isalive-api + description: A simple health check API +spec: + type: openapi + lifecycle: experimental + owner: ${{ values.owner }} + system: devops-infra + definition: | + openapi: "3.0.0" + info: + version: 1.0.0 + title: IsAlive API + license: + name: MIT + servers: + - url: http://localhost:5000 + paths: + /v1/isalive: + get: + responses: + '200': + description: Confirmation that the service is alive + summary: Health check to be used inside Kubernetes + ... + +