updating branch restriction code to use the bitubcket package instead of rest APIs, tests adjusted as well

Signed-off-by: liad.shachoach <liad.shachoach@controlup.com>
This commit is contained in:
liad.shachoach
2025-02-28 18:12:56 +02:00
parent 9fa7e212d1
commit 231acf448f
6 changed files with 317 additions and 246 deletions
@@ -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"
},
@@ -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',
});
});
});
@@ -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);
});
});
@@ -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<object>;
groups?: Array<object>;
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<object>;
groups?: Array<object>;
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));
},
});
}
@@ -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;
@@ -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
...