From 959e6ecc5a7b76f67407f52a5a083740fd28fdec Mon Sep 17 00:00:00 2001 From: Jake Smith Date: Sat, 22 Nov 2025 01:47:59 +0000 Subject: [PATCH 1/6] Add Bitbucket Cloud OAuth support Signed-off-by: Jake Smith --- docs/integrations/bitbucketCloud/locations.md | 13 +- .../urlReader/lib/BitbucketCloudUrlReader.ts | 16 ++- packages/integration/config.d.ts | 12 +- .../src/bitbucketCloud/config.test.ts | 51 +++++++ .../integration/src/bitbucketCloud/config.ts | 30 +++- .../src/bitbucketCloud/core.test.ts | 94 ++++++++++++- .../integration/src/bitbucketCloud/core.ts | 131 +++++++++++++++++- .../integration/src/bitbucketCloud/index.ts | 1 + .../src/BitbucketCloudClient.ts | 37 +++-- .../src/actions/bitbucketCloud.ts | 2 +- .../bitbucketCloudBranchRestriction.test.ts | 12 +- .../bitbucketCloudBranchRestriction.ts | 4 +- .../src/actions/bitbucketCloudPipelinesRun.ts | 2 +- .../src/actions/bitbucketCloudPullRequest.ts | 2 +- .../src/actions/helpers.ts | 37 ++++- 15 files changed, 398 insertions(+), 46 deletions(-) diff --git a/docs/integrations/bitbucketCloud/locations.md b/docs/integrations/bitbucketCloud/locations.md index 6b735171a4..807076c9e9 100644 --- a/docs/integrations/bitbucketCloud/locations.md +++ b/docs/integrations/bitbucketCloud/locations.md @@ -32,6 +32,15 @@ integrations: appPassword: my-password ``` +OAuth 2.0 client credentials flow: + +```yaml +integrations: + bitbucketCloud: + - clientId: client-id + clientSecret: client-secret +``` + :::note Note A public Bitbucket Cloud provider is added automatically at startup for @@ -41,7 +50,7 @@ convenience, so you only need to list it if you want to supply credentials. :::note Note -The credential required for this type is either an [Api token](https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/) or an [App Password](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/). An Atlassian Account API key will not work. +The credential required for this type is either an [Api token](https://support.atlassian.com/bitbucket-cloud/docs/using-api-tokens/), an [App Password](https://support.atlassian.com/bitbucket-cloud/docs/app-passwords/) or an [OAuth 2.0 client credentials](https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/). An Atlassian Account API key will not work. ::: @@ -55,3 +64,5 @@ This one entry will have the following elements: neither a username nor token are supplied, anonymous access will be used. - `token`: The token used to authenticate requests. - `appPassword`: The app password for the Bitbucket Cloud user. +- `clientId`: The OAuth client ID for Bitbucket Cloud (used with `clientSecret` for OAuth 2.0 client credentials flow). +- `clientSecret`: The OAuth client secret for Bitbucket Cloud (used with `clientId` for OAuth 2.0 client credentials flow). diff --git a/packages/backend-defaults/src/entrypoints/urlReader/lib/BitbucketCloudUrlReader.ts b/packages/backend-defaults/src/entrypoints/urlReader/lib/BitbucketCloudUrlReader.ts index ff6ec3bb27..73302d37e5 100644 --- a/packages/backend-defaults/src/entrypoints/urlReader/lib/BitbucketCloudUrlReader.ts +++ b/packages/backend-defaults/src/entrypoints/urlReader/lib/BitbucketCloudUrlReader.ts @@ -68,7 +68,15 @@ export class BitbucketCloudUrlReader implements UrlReaderService { ) { this.integration = integration; this.deps = deps; - const { host, username, appPassword, token } = integration.config; + const { host, username, appPassword, token, clientId, clientSecret } = + integration.config; + + // Validate: OAuth requires both clientId and clientSecret + if ((clientId && !clientSecret) || (clientSecret && !clientId)) { + throw new Error( + `Bitbucket Cloud integration has incomplete OAuth configuration. Both clientId and clientSecret are required.`, + ); + } if (username && !token && !appPassword) { throw new Error( @@ -91,7 +99,7 @@ export class BitbucketCloudUrlReader implements UrlReaderService { url, this.integration.config, ); - const requestOptions = getBitbucketCloudRequestOptions( + const requestOptions = await getBitbucketCloudRequestOptions( this.integration.config, ); @@ -149,7 +157,7 @@ export class BitbucketCloudUrlReader implements UrlReaderService { ); const archiveResponse = await fetch( downloadUrl, - getBitbucketCloudRequestOptions(this.integration.config), + await getBitbucketCloudRequestOptions(this.integration.config), ); if (!archiveResponse.ok) { const message = `Failed to read tree from ${url}, ${archiveResponse.status} ${archiveResponse.statusText}`; @@ -250,7 +258,7 @@ export class BitbucketCloudUrlReader implements UrlReaderService { const commitsResponse = await fetch( commitsApiUrl, - getBitbucketCloudRequestOptions(this.integration.config), + await getBitbucketCloudRequestOptions(this.integration.config), ); if (!commitsResponse.ok) { const message = `Failed to retrieve commits from ${commitsApiUrl}, ${commitsResponse.status} ${commitsResponse.statusText}`; diff --git a/packages/integration/config.d.ts b/packages/integration/config.d.ts index a49c4cc0d6..4278a8fcfe 100644 --- a/packages/integration/config.d.ts +++ b/packages/integration/config.d.ts @@ -174,7 +174,7 @@ export interface Config { * The username to use for authenticated requests. * @visibility secret */ - username: string; + username?: string; /** * Token used to authenticate requests. * @visibility secret @@ -186,6 +186,16 @@ export interface Config { * @deprecated Use `token` instead. */ appPassword?: string; + /** + * OAuth client ID for Bitbucket Cloud. + * @visibility secret + */ + clientId?: string; + /** + * OAuth client secret for Bitbucket Cloud. + * @visibility secret + */ + clientSecret?: string; /** * PGP signing key for signing commits. * @visibility secret diff --git a/packages/integration/src/bitbucketCloud/config.test.ts b/packages/integration/src/bitbucketCloud/config.test.ts index 9488c8b5f7..57e8803a14 100644 --- a/packages/integration/src/bitbucketCloud/config.test.ts +++ b/packages/integration/src/bitbucketCloud/config.test.ts @@ -85,6 +85,39 @@ describe('readBitbucketCloudIntegrationConfig', () => { ).toThrow(/token/); }); + it('reads OAuth configuration', () => { + const output = readBitbucketCloudIntegrationConfig( + buildConfig({ + clientId: 'my-client-id', + clientSecret: 'my-client-secret', + }), + ); + expect(output).toEqual({ + host: BITBUCKET_CLOUD_HOST, + apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL, + clientId: 'my-client-id', + clientSecret: 'my-client-secret', + }); + }); + + it('rejects incomplete OAuth configuration', () => { + expect(() => + readBitbucketCloudIntegrationConfig( + buildConfig({ + clientId: 'my-client-id', + }), + ), + ).toThrow(/incomplete OAuth configuration/); + + expect(() => + readBitbucketCloudIntegrationConfig( + buildConfig({ + clientSecret: 'my-client-secret', + }), + ), + ).toThrow(/incomplete OAuth configuration/); + }); + it('credentials hidden on the frontend', async () => { const frontendConfig = await buildFrontendConfig({ token: 't', @@ -103,6 +136,24 @@ describe('readBitbucketCloudIntegrationConfig', () => { ]); }); + it('OAuth credentials hidden on the frontend', async () => { + const frontendConfig = await buildFrontendConfig({ + clientId: 'my-client-id', + clientSecret: 'my-client-secret', + }); + expect( + readBitbucketCloudIntegrationConfigs( + frontendConfig.getOptionalConfigArray('integrations.bitbucketCloud') ?? + [], + ), + ).toEqual([ + { + host: BITBUCKET_CLOUD_HOST, + apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL, + }, + ]); + }); + // TODO: appPassword can be removed once fully // deprecated by BitBucket on 9th June 2026. describe('handles deprecated appPassword', () => { diff --git a/packages/integration/src/bitbucketCloud/config.ts b/packages/integration/src/bitbucketCloud/config.ts index 0e08e95a79..e47b215f98 100644 --- a/packages/integration/src/bitbucketCloud/config.ts +++ b/packages/integration/src/bitbucketCloud/config.ts @@ -54,6 +54,20 @@ export type BitbucketCloudIntegrationConfig = { */ token?: string; + /** + * The OAuth client ID for Bitbucket Cloud. + * + * See https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/ + */ + clientId?: string; + + /** + * The OAuth client secret for Bitbucket Cloud. + * + * See https://support.atlassian.com/bitbucket-cloud/docs/use-oauth-on-bitbucket-cloud/ + */ + clientSecret?: string; + /** PGP private key for signing commits. */ commitSigningKey?: string; }; @@ -71,24 +85,36 @@ export function readBitbucketCloudIntegrationConfig( const apiBaseUrl = BITBUCKET_CLOUD_API_BASE_URL; // If config is provided, we assume authenticated access is desired // (as the anonymous one is provided by default). - const username = config.getString('username'); + const username = config.getOptionalString('username'); // TODO: appPassword can be removed once fully // deprecated by BitBucket on 9th June 2026. const appPassword = config.getOptionalString('appPassword')?.trim(); const token = config.getOptionalString('token'); + const clientId = config.getOptionalString('clientId')?.trim(); + const clientSecret = config.getOptionalString('clientSecret')?.trim(); - if (!token && !appPassword) { + // Validate: if username is provided, token or appPassword is required + if (username && !token && !appPassword) { throw new Error( `Bitbucket Cloud integration must be configured with as username and either a token or an appPassword.`, ); } + // Validate: OAuth requires both clientId and clientSecret + if ((clientId && !clientSecret) || (clientSecret && !clientId)) { + throw new Error( + `Bitbucket Cloud integration has incomplete OAuth configuration. Both clientId and clientSecret are required.`, + ); + } + return { host, apiBaseUrl, username, appPassword, token, + clientId, + clientSecret, commitSigningKey: config.getOptionalString('commitSigningKey'), }; } diff --git a/packages/integration/src/bitbucketCloud/core.test.ts b/packages/integration/src/bitbucketCloud/core.test.ts index fa8fc2ae43..5573dec735 100644 --- a/packages/integration/src/bitbucketCloud/core.test.ts +++ b/packages/integration/src/bitbucketCloud/core.test.ts @@ -28,13 +28,15 @@ import { // Mock constants const BITBUCKET_CLOUD_HOST = 'bitbucket.org'; const BITBUCKET_CLOUD_API_BASE_URL = 'https://api.bitbucket.org/2.0'; +const BITBUCKET_CLOUD_OAUTH_TOKEN_URL = + 'https://bitbucket.org/site/oauth2/access_token'; describe('bitbucketCloud core', () => { const worker = setupServer(); registerMswTestHooks(worker); describe('getBitbucketCloudRequestOptions', () => { - it('insert basic auth when needed', () => { + it('insert basic auth when needed', async () => { const withUsernameAndToken: BitbucketCloudIntegrationConfig = { host: BITBUCKET_CLOUD_HOST, apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL, @@ -54,20 +56,98 @@ describe('bitbucketCloud core', () => { apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL, }; expect( - (getBitbucketCloudRequestOptions(withUsernameAndToken).headers as any) + (await getBitbucketCloudRequestOptions(withUsernameAndToken)).headers .Authorization, ).toEqual('Basic c29tZS11c2VyQGRvbWFpbi5jb206bXktdG9rZW4='); expect( - ( - getBitbucketCloudRequestOptions(withUsernameAndPassword) - .headers as any - ).Authorization, + (await getBitbucketCloudRequestOptions(withUsernameAndPassword)).headers + .Authorization, ).toEqual('Basic c29tZS11c2VyOm15LXNlY3JldA=='); expect( - (getBitbucketCloudRequestOptions(withoutUsername).headers as any) + (await getBitbucketCloudRequestOptions(withoutUsername)).headers .Authorization, ).toBeUndefined(); }); + + it('uses OAuth Bearer token when clientId and clientSecret provided', async () => { + worker.use( + rest.post(BITBUCKET_CLOUD_OAUTH_TOKEN_URL, (_, res, ctx) => + res( + ctx.status(200), + ctx.json({ + access_token: 'test-oauth-token', + expires_in: 3600, + }), + ), + ), + ); + + const withOAuth: BitbucketCloudIntegrationConfig = { + host: BITBUCKET_CLOUD_HOST, + apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL, + clientId: 'test-client-id', + clientSecret: 'test-client-secret', + }; + + const result = await getBitbucketCloudRequestOptions(withOAuth); + expect(result.headers.Authorization).toEqual('Bearer test-oauth-token'); + }); + + it('caches OAuth tokens', async () => { + let callCount = 0; + worker.use( + rest.post(BITBUCKET_CLOUD_OAUTH_TOKEN_URL, (_, res, ctx) => { + callCount++; + return res( + ctx.status(200), + ctx.json({ + access_token: 'cached-oauth-token', + expires_in: 3600, + }), + ); + }), + ); + + const withOAuth: BitbucketCloudIntegrationConfig = { + host: BITBUCKET_CLOUD_HOST, + apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL, + clientId: 'cache-test-client', + clientSecret: 'cache-test-secret', + }; + + // First call should fetch token + const result1 = await getBitbucketCloudRequestOptions(withOAuth); + expect(result1.headers.Authorization).toEqual( + 'Bearer cached-oauth-token', + ); + expect(callCount).toBe(1); + + // Second call should use cached token + const result2 = await getBitbucketCloudRequestOptions(withOAuth); + expect(result2.headers.Authorization).toEqual( + 'Bearer cached-oauth-token', + ); + expect(callCount).toBe(1); // Should still be 1 + }); + + it('handles OAuth token fetch errors', async () => { + worker.use( + rest.post(BITBUCKET_CLOUD_OAUTH_TOKEN_URL, (_, res, ctx) => + res(ctx.status(401), ctx.json({ error: 'invalid_client' })), + ), + ); + + const withOAuth: BitbucketCloudIntegrationConfig = { + host: BITBUCKET_CLOUD_HOST, + apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL, + clientId: 'invalid-client', + clientSecret: 'invalid-secret', + }; + + await expect(getBitbucketCloudRequestOptions(withOAuth)).rejects.toThrow( + /Failed to fetch OAuth token/, + ); + }); }); describe('getBitbucketCloudFileFetchUrl', () => { diff --git a/packages/integration/src/bitbucketCloud/core.ts b/packages/integration/src/bitbucketCloud/core.ts index a97de872de..7455a488fe 100644 --- a/packages/integration/src/bitbucketCloud/core.ts +++ b/packages/integration/src/bitbucketCloud/core.ts @@ -14,9 +14,119 @@ * limitations under the License. */ +import { createHash } from 'crypto'; import fetch from 'cross-fetch'; import parseGitUrl from 'git-url-parse'; import { BitbucketCloudIntegrationConfig } from './config'; +import { DateTime } from 'luxon'; + +type OAuthTokenData = { + token: string; + expiresAt: DateTime; +}; + +// In-memory token cache keyed by hashed credentials +const tokenCache = new Map(); + +// Track in-flight token refresh requests to prevent concurrent fetches +const refreshPromises = new Map>(); + +/** + * Creates a cache key from OAuth credentials by hashing them. + * This prevents storing credentials in memory in plain text. + */ +function createCacheKey(clientId: string, clientSecret: string): string { + return createHash('sha256') + .update(`${clientId}:${clientSecret}`) + .digest('hex'); +} + +/** + * Fetches an OAuth access token from Bitbucket Cloud using client credentials flow. + * Tokens are cached with a 10-minute grace period to account for clock skew. + * Implements concurrent refresh protection to prevent multiple simultaneous token requests. + * + * @param clientId - OAuth client ID + * @param clientSecret - OAuth client secret + * @public + */ +export async function getBitbucketCloudOAuthToken( + clientId: string, + clientSecret: string, +): Promise { + const cacheKey = createCacheKey(clientId, clientSecret); + + // Check cache + const cached = tokenCache.get(cacheKey); + if (cached && DateTime.now() < cached.expiresAt) { + return cached.token; + } + + // Check if there's already a refresh in progress + const inFlight = refreshPromises.get(cacheKey); + if (inFlight) { + return inFlight; + } + + // Start a new token fetch and track it + const refreshPromise = (async () => { + try { + // Fetch new token + const credentials = Buffer.from( + `${clientId}:${clientSecret}`, + 'utf8', + ).toString('base64'); + + const response = await fetch( + 'https://bitbucket.org/site/oauth2/access_token', + { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + Authorization: `Basic ${credentials}`, + }, + body: 'grant_type=client_credentials', + }, + ); + + if (!response.ok) { + throw new Error( + `Failed to fetch OAuth token from Bitbucket Cloud: ${response.status} ${response.statusText}`, + ); + } + + const data = await response.json(); + + if (!data.access_token) { + throw new Error('OAuth token response missing access_token field'); + } + + // Calculate expiration with 10-minute grace period + const expiresIn = data.expires_in || 3600; // Default to 1 hour + const expiresAt = DateTime.now() + .plus({ seconds: expiresIn }) + .minus({ minutes: 10 }); + + // Cache the token + tokenCache.set(cacheKey, { + token: data.access_token, + expiresAt, + }); + + return data.access_token; + } catch (error) { + throw new Error( + `Failed to fetch OAuth token for Bitbucket Cloud: ${error}`, + ); + } finally { + // Clean up the in-flight promise tracking + refreshPromises.delete(cacheKey); + } + })(); + + refreshPromises.set(cacheKey, refreshPromise); + return refreshPromise; +} /** * Given a URL pointing to a path on a provider, returns the default branch. @@ -34,7 +144,7 @@ export async function getBitbucketCloudDefaultBranch( const branchUrl = `${config.apiBaseUrl}/repositories/${project}/${repoName}`; const response = await fetch( branchUrl, - getBitbucketCloudRequestOptions(config), + await getBitbucketCloudRequestOptions(config), ); if (!response.ok) { @@ -118,18 +228,29 @@ export function getBitbucketCloudFileFetchUrl( /** * Gets the request options necessary to make requests to a given provider. * Returns headers for authenticating with Bitbucket Cloud. - * Supports both username/token and username/appPassword auth. + * Supports OAuth (clientId/clientSecret), username/token, and username/appPassword auth. * * @param config - The relevant provider config * @public */ -export function getBitbucketCloudRequestOptions( +export async function getBitbucketCloudRequestOptions( config: BitbucketCloudIntegrationConfig, -): { +): Promise<{ headers: Record; -} { +}> { const headers: Record = {}; + // OAuth authentication (clientId/clientSecret) + if (config.clientId && config.clientSecret) { + const token = await getBitbucketCloudOAuthToken( + config.clientId, + config.clientSecret, + ); + headers.Authorization = `Bearer ${token}`; + return { headers }; + } + + // Basic authentication (username + token/appPassword) // TODO: appPassword can be removed once fully // deprecated by BitBucket on 9th June 2026. if (config.username && (config.token ?? config.appPassword)) { diff --git a/packages/integration/src/bitbucketCloud/index.ts b/packages/integration/src/bitbucketCloud/index.ts index 7119d3ea90..d0c11136b3 100644 --- a/packages/integration/src/bitbucketCloud/index.ts +++ b/packages/integration/src/bitbucketCloud/index.ts @@ -24,5 +24,6 @@ export { getBitbucketCloudDefaultBranch, getBitbucketCloudDownloadUrl, getBitbucketCloudFileFetchUrl, + getBitbucketCloudOAuthToken, getBitbucketCloudRequestOptions, } from './core'; diff --git a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts index adbf08cf40..eb40b51b5b 100644 --- a/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts +++ b/plugins/bitbucket-cloud-common/src/BitbucketCloudClient.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import { BitbucketCloudIntegrationConfig } from '@backstage/integration'; +import { + BitbucketCloudIntegrationConfig, + getBitbucketCloudOAuthToken, +} from '@backstage/integration'; import fetch, { Request } from 'cross-fetch'; import { Models } from './models'; import { WithPagination } from './pagination'; @@ -139,22 +142,32 @@ export class BitbucketCloudClient { } private async request(req: Request): Promise { - return fetch(req, { headers: this.getAuthHeaders() }).then( - (response: Response) => { - if (!response.ok) { - throw new Error( - `Unexpected response for ${req.method} ${req.url}. Expected 200 but got ${response.status} - ${response.statusText}`, - ); - } + const headers = await this.getAuthHeaders(); + return fetch(req, { headers }).then((response: Response) => { + if (!response.ok) { + throw new Error( + `Unexpected response for ${req.method} ${req.url}. Expected 200 but got ${response.status} - ${response.statusText}`, + ); + } - return response; - }, - ); + return response; + }); } - private getAuthHeaders(): Record { + private async getAuthHeaders(): Promise> { const headers: Record = {}; + // OAuth authentication (clientId/clientSecret) + if (this.config.clientId && this.config.clientSecret) { + const token = await getBitbucketCloudOAuthToken( + this.config.clientId, + this.config.clientSecret, + ); + headers.Authorization = `Bearer ${token}`; + return headers; + } + + // Basic authentication (username/token or username/appPassword) if ( this.config.username && (this.config.token ?? this.config.appPassword) diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.ts index 722bf01303..16a7e676e1 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.ts @@ -216,7 +216,7 @@ export function createPublishBitbucketCloudAction(options: { ); } - const authorization = getAuthorizationHeader( + const authorization = await getAuthorizationHeader( ctx.input.token ? { token: ctx.input.token } : integrationConfig.config, ); 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 55ee430091..2ac54894d5 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 @@ -22,11 +22,11 @@ jest.mock('bitbucket', () => ({ })); describe('bitbucketCloud:branchRestriction:create', () => { - it('getBitbucketClient should return the correct headers with username and password', () => { + it('getBitbucketClient should return the correct headers with username and password', async () => { expect.assertions(1); const username = 'username'; const password = 'password'; - getBitbucketClient({ username: username, appPassword: password }); + await getBitbucketClient({ username: username, appPassword: password }); expect(Bitbucket).toHaveBeenCalledWith({ auth: { username: username, @@ -35,12 +35,14 @@ describe('bitbucketCloud:branchRestriction:create', () => { }); }); - it('getBitbucketClient should throw if only one of username or password is provided', () => { + it('getBitbucketClient should throw if only one of username or password is provided', async () => { expect.assertions(2); const username = 'username'; const password = 'password'; - expect(() => getBitbucketClient({ username })).toThrow(Error); - expect(() => getBitbucketClient({ appPassword: password })).toThrow(Error); + await expect(getBitbucketClient({ username })).rejects.toThrow(Error); + await expect(getBitbucketClient({ appPassword: password })).rejects.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 233818e00a..a12f63f980 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudBranchRestriction.ts @@ -37,6 +37,8 @@ const createBitbucketCloudBranchRestriction = async (opts: { token?: string; username?: string; appPassword?: string; + clientId?: string; + clientSecret?: string; }; }) => { const { @@ -52,7 +54,7 @@ const createBitbucketCloudBranchRestriction = async (opts: { authorization, } = opts; - const bitbucket = getBitbucketClient(authorization); + const bitbucket = await getBitbucketClient(authorization); return await bitbucket.branchrestrictions.create({ _body: { groups: groups, diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.ts index 4bf9226db7..372f45c928 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPipelinesRun.ts @@ -68,7 +68,7 @@ export const createBitbucketPipelinesRunAction = (options: { const host = 'bitbucket.org'; const integrationConfig = integrations.bitbucketCloud.byHost(host); - const authorization = getAuthorizationHeader( + const authorization = await getAuthorizationHeader( token ? { token } : integrationConfig!.config, ); let response: Response; diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts index 9969f13eb2..08e19f89bf 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts @@ -317,7 +317,7 @@ export function createPublishBitbucketCloudPullRequestAction(options: { ); } - const authorization = getAuthorizationHeader( + const authorization = await getAuthorizationHeader( ctx.input.token ? { token: ctx.input.token } : integrationConfig.config, ); 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 2e68d4e8b8..47a212fcab 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/helpers.ts @@ -15,12 +15,28 @@ */ import { Bitbucket } from 'bitbucket'; +import { getBitbucketCloudOAuthToken } from '@backstage/integration'; -export const getBitbucketClient = (config: { +export const getBitbucketClient = async (config: { token?: string; username?: string; appPassword?: string; + clientId?: string; + clientSecret?: string; }) => { + // If OAuth credentials provided, fetch token + if (config.clientId && config.clientSecret) { + const token = await getBitbucketCloudOAuthToken( + config.clientId, + config.clientSecret, + ); + return new Bitbucket({ + auth: { + token, + }, + }); + } + if (config.token) { return new Bitbucket({ auth: { @@ -38,15 +54,26 @@ export const getBitbucketClient = (config: { }); } throw new Error( - `Authorization has not been provided for Bitbucket Cloud. Please add either provide a username and token or username and appPassword to the Integrations config`, + `Authorization has not been provided for Bitbucket Cloud. Please provide either OAuth credentials (clientId/clientSecret), username and token, or username and appPassword in the Integrations config`, ); }; -export const getAuthorizationHeader = (config: { +export const getAuthorizationHeader = async (config: { username?: string; appPassword?: string; token?: string; -}) => { + clientId?: string; + clientSecret?: string; +}): Promise => { + // OAuth authentication + if (config.clientId && config.clientSecret) { + const token = await getBitbucketCloudOAuthToken( + config.clientId, + config.clientSecret, + ); + return `Bearer ${token}`; + } + // TODO: appPassword can be removed once fully // deprecated by BitBucket on 9th June 2026. if (config.username && (config.token ?? config.appPassword)) { @@ -62,6 +89,6 @@ export const getAuthorizationHeader = (config: { } throw new Error( - `Authorization has not been provided for Bitbucket Cloud. Please add either provide a username and token or username and appPassword to the Integrations config`, + `Authorization has not been provided for Bitbucket Cloud. Please provide either OAuth credentials (clientId/clientSecret), username and token, or username and appPassword in the Integrations config`, ); }; From 37fba1d8ec55d63d478b30bcb796d6f2f5601962 Mon Sep 17 00:00:00 2001 From: Jake Smith Date: Sat, 22 Nov 2025 03:41:05 +0000 Subject: [PATCH 2/6] Add config example, changeset and API reports Signed-off-by: Jake Smith --- .changeset/dark-dingos-see.md | 27 +++++++++++++++++++ app-config.yaml | 4 +++ packages/integration/report.api.md | 12 +++++++-- .../report.api.md | 12 ++++----- 4 files changed, 47 insertions(+), 8 deletions(-) create mode 100644 .changeset/dark-dingos-see.md diff --git a/.changeset/dark-dingos-see.md b/.changeset/dark-dingos-see.md new file mode 100644 index 0000000000..34a3f15c6f --- /dev/null +++ b/.changeset/dark-dingos-see.md @@ -0,0 +1,27 @@ +--- +'@backstage/plugin-scaffolder-backend-module-bitbucket-cloud': minor +'@backstage/plugin-bitbucket-cloud-common': patch +'@backstage/integration': minor +'@backstage/backend-defaults': patch +--- + +Added support for Bitbucket Cloud OAuth. This introduces an alternative authentication method using a workspace OAuth consumer, alongside App Passwords (deprecated) and API tokens. OAuth does not require a bot or service account and avoids token expiry issues. + +**BREAKING CHANGES** + +- **@backstage/integration** (`src/bitbucketCloud/core.ts`) + + - `getBitbucketCloudRequestOptions` now returns a `Promise` and **must** be awaited. + +- **@backstage/plugin-scaffolder-backend-module-bitbucket-cloud** (`src/actions/helpers.ts`) + - `getBitbucketClient` now returns a `Promise` and **must** be awaited. + - `getAuthorizationHeader` now returns a `Promise` and **must** be awaited. + +**OAuth usage example** + +```yaml +integrations: + bitbucketCloud: + - clientId: client-id + clientSecret: client-secret +``` diff --git a/app-config.yaml b/app-config.yaml index 08c97c1172..54ece44da3 100644 --- a/app-config.yaml +++ b/app-config.yaml @@ -109,8 +109,12 @@ integrations: token: ${GITLAB_TOKEN} ### Example for how to add a bitbucket cloud integration # bitbucketCloud: + # # Using API token # - username: ${BITBUCKET_USERNAME} # token: ${BITBUCKET_API_TOKEN} + # # Using OAuth + # - clientId: ${BITBUCKET_CLIENT_ID} + # clientSecret: ${BITBUCKET_CLIENT_SECRET} ### Example for how to add your bitbucket server instance using the API: # - host: server.bitbucket.com # apiBaseUrl: server.bitbucket.com diff --git a/packages/integration/report.api.md b/packages/integration/report.api.md index d065f441f0..561dea979d 100644 --- a/packages/integration/report.api.md +++ b/packages/integration/report.api.md @@ -250,6 +250,8 @@ export type BitbucketCloudIntegrationConfig = { username?: string; appPassword?: string; token?: string; + clientId?: string; + clientSecret?: string; commitSigningKey?: string; }; @@ -450,12 +452,18 @@ export function getBitbucketCloudFileFetchUrl( config: BitbucketCloudIntegrationConfig, ): string; +// @public +export function getBitbucketCloudOAuthToken( + clientId: string, + clientSecret: string, +): Promise; + // @public export function getBitbucketCloudRequestOptions( config: BitbucketCloudIntegrationConfig, -): { +): Promise<{ headers: Record; -}; +}>; // @public @deprecated export function getBitbucketDefaultBranch( diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md b/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md index af34609213..9a42763078 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md @@ -25,12 +25,6 @@ export const createBitbucketPipelinesRunAction: (options: { | { type?: string | undefined; source?: string | undefined; - selector?: - | { - type: string; - pattern: string; - } - | undefined; pull_request?: | { id: string; @@ -45,6 +39,12 @@ export const createBitbucketPipelinesRunAction: (options: { destination?: string | undefined; ref_name?: string | undefined; ref_type?: string | undefined; + selector?: + | { + type: string; + pattern: string; + } + | undefined; destination_commit?: | { hash: string; From 1956b3f39881d86eeae177d41f8a14d4be6124cd Mon Sep 17 00:00:00 2001 From: Jake Smith Date: Sat, 22 Nov 2025 23:37:23 +0000 Subject: [PATCH 3/6] Remove cachekey logic, not needed as only a single bitbucket connection is supported, rework tests and update api report Signed-off-by: Jake Smith --- .../src/bitbucketCloud/core.test.ts | 56 +++++-------------- .../integration/src/bitbucketCloud/core.ts | 40 ++++--------- .../report.api.md | 12 ++-- 3 files changed, 33 insertions(+), 75 deletions(-) diff --git a/packages/integration/src/bitbucketCloud/core.test.ts b/packages/integration/src/bitbucketCloud/core.test.ts index 5573dec735..4f46b5ad42 100644 --- a/packages/integration/src/bitbucketCloud/core.test.ts +++ b/packages/integration/src/bitbucketCloud/core.test.ts @@ -69,16 +69,11 @@ describe('bitbucketCloud core', () => { ).toBeUndefined(); }); - it('uses OAuth Bearer token when clientId and clientSecret provided', async () => { + it('handles OAuth token fetch errors', async () => { + // Test error handling worker.use( rest.post(BITBUCKET_CLOUD_OAUTH_TOKEN_URL, (_, res, ctx) => - res( - ctx.status(200), - ctx.json({ - access_token: 'test-oauth-token', - expires_in: 3600, - }), - ), + res(ctx.status(401), ctx.json({ error: 'invalid_client' })), ), ); @@ -89,11 +84,13 @@ describe('bitbucketCloud core', () => { clientSecret: 'test-client-secret', }; - const result = await getBitbucketCloudRequestOptions(withOAuth); - expect(result.headers.Authorization).toEqual('Bearer test-oauth-token'); + await expect(getBitbucketCloudRequestOptions(withOAuth)).rejects.toThrow( + /Failed to fetch OAuth token/, + ); }); - it('caches OAuth tokens', async () => { + it('uses OAuth Bearer token and caches it', async () => { + // Test OAuth + caching let callCount = 0; worker.use( rest.post(BITBUCKET_CLOUD_OAUTH_TOKEN_URL, (_, res, ctx) => { @@ -101,7 +98,7 @@ describe('bitbucketCloud core', () => { return res( ctx.status(200), ctx.json({ - access_token: 'cached-oauth-token', + access_token: 'test-oauth-token', expires_in: 3600, }), ); @@ -111,42 +108,19 @@ describe('bitbucketCloud core', () => { const withOAuth: BitbucketCloudIntegrationConfig = { host: BITBUCKET_CLOUD_HOST, apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL, - clientId: 'cache-test-client', - clientSecret: 'cache-test-secret', + clientId: 'test-client-id', + clientSecret: 'test-client-secret', }; // First call should fetch token const result1 = await getBitbucketCloudRequestOptions(withOAuth); - expect(result1.headers.Authorization).toEqual( - 'Bearer cached-oauth-token', - ); + expect(result1.headers.Authorization).toEqual('Bearer test-oauth-token'); expect(callCount).toBe(1); - // Second call should use cached token + // Second call should use cached token (proves caching works) const result2 = await getBitbucketCloudRequestOptions(withOAuth); - expect(result2.headers.Authorization).toEqual( - 'Bearer cached-oauth-token', - ); - expect(callCount).toBe(1); // Should still be 1 - }); - - it('handles OAuth token fetch errors', async () => { - worker.use( - rest.post(BITBUCKET_CLOUD_OAUTH_TOKEN_URL, (_, res, ctx) => - res(ctx.status(401), ctx.json({ error: 'invalid_client' })), - ), - ); - - const withOAuth: BitbucketCloudIntegrationConfig = { - host: BITBUCKET_CLOUD_HOST, - apiBaseUrl: BITBUCKET_CLOUD_API_BASE_URL, - clientId: 'invalid-client', - clientSecret: 'invalid-secret', - }; - - await expect(getBitbucketCloudRequestOptions(withOAuth)).rejects.toThrow( - /Failed to fetch OAuth token/, - ); + expect(result2.headers.Authorization).toEqual('Bearer test-oauth-token'); + expect(callCount).toBe(1); // Still 1, proving cache was used }); }); diff --git a/packages/integration/src/bitbucketCloud/core.ts b/packages/integration/src/bitbucketCloud/core.ts index 7455a488fe..9beb3bd748 100644 --- a/packages/integration/src/bitbucketCloud/core.ts +++ b/packages/integration/src/bitbucketCloud/core.ts @@ -14,7 +14,6 @@ * limitations under the License. */ -import { createHash } from 'crypto'; import fetch from 'cross-fetch'; import parseGitUrl from 'git-url-parse'; import { BitbucketCloudIntegrationConfig } from './config'; @@ -25,21 +24,11 @@ type OAuthTokenData = { expiresAt: DateTime; }; -// In-memory token cache keyed by hashed credentials -const tokenCache = new Map(); +// In-memory token cache (single entry since there's only one Bitbucket Cloud integration) +let cachedToken: OAuthTokenData | undefined; -// Track in-flight token refresh requests to prevent concurrent fetches -const refreshPromises = new Map>(); - -/** - * Creates a cache key from OAuth credentials by hashing them. - * This prevents storing credentials in memory in plain text. - */ -function createCacheKey(clientId: string, clientSecret: string): string { - return createHash('sha256') - .update(`${clientId}:${clientSecret}`) - .digest('hex'); -} +// Track in-flight token refresh request to prevent concurrent fetches +let refreshPromise: Promise | undefined; /** * Fetches an OAuth access token from Bitbucket Cloud using client credentials flow. @@ -54,22 +43,18 @@ export async function getBitbucketCloudOAuthToken( clientId: string, clientSecret: string, ): Promise { - const cacheKey = createCacheKey(clientId, clientSecret); - // Check cache - const cached = tokenCache.get(cacheKey); - if (cached && DateTime.now() < cached.expiresAt) { - return cached.token; + if (cachedToken && DateTime.now() < cachedToken.expiresAt) { + return cachedToken.token; } // Check if there's already a refresh in progress - const inFlight = refreshPromises.get(cacheKey); - if (inFlight) { - return inFlight; + if (refreshPromise) { + return refreshPromise; } // Start a new token fetch and track it - const refreshPromise = (async () => { + refreshPromise = (async () => { try { // Fetch new token const credentials = Buffer.from( @@ -108,10 +93,10 @@ export async function getBitbucketCloudOAuthToken( .minus({ minutes: 10 }); // Cache the token - tokenCache.set(cacheKey, { + cachedToken = { token: data.access_token, expiresAt, - }); + }; return data.access_token; } catch (error) { @@ -120,11 +105,10 @@ export async function getBitbucketCloudOAuthToken( ); } finally { // Clean up the in-flight promise tracking - refreshPromises.delete(cacheKey); + refreshPromise = undefined; } })(); - refreshPromises.set(cacheKey, refreshPromise); return refreshPromise; } diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md b/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md index 9a42763078..af34609213 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/report.api.md @@ -25,6 +25,12 @@ export const createBitbucketPipelinesRunAction: (options: { | { type?: string | undefined; source?: string | undefined; + selector?: + | { + type: string; + pattern: string; + } + | undefined; pull_request?: | { id: string; @@ -39,12 +45,6 @@ export const createBitbucketPipelinesRunAction: (options: { destination?: string | undefined; ref_name?: string | undefined; ref_type?: string | undefined; - selector?: - | { - type: string; - pattern: string; - } - | undefined; destination_commit?: | { hash: string; From e3ba350ee384942b80ce98f992571bd197b6adad Mon Sep 17 00:00:00 2001 From: Jake Smith Date: Sun, 23 Nov 2025 02:46:14 +0000 Subject: [PATCH 4/6] Add OAuth support for bitbucket publish action Signed-off-by: Jake Smith --- .../src/actions/bitbucketCloud.ts | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.ts index 16a7e676e1..a278410639 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloud.ts @@ -25,6 +25,7 @@ import { import { Config } from '@backstage/config'; import { getAuthorizationHeader } from './helpers'; +import { getBitbucketCloudOAuthToken } from '@backstage/integration'; import { examples } from './bitbucketCloud.examples'; const createRepository = async (opts: { @@ -249,6 +250,18 @@ export function createPublishBitbucketCloudAction(options: { username: 'x-token-auth', password: ctx.input.token, }; + } else if ( + integrationConfig.config.clientId && + integrationConfig.config.clientSecret + ) { + const token = await getBitbucketCloudOAuthToken( + integrationConfig.config.clientId, + integrationConfig.config.clientSecret, + ); + auth = { + username: 'x-token-auth', + password: token, + }; } else { if ( !integrationConfig.config.username || From 864d969252c775ee6b6ca985db4c270829e36718 Mon Sep 17 00:00:00 2001 From: Jake Smith Date: Sun, 23 Nov 2025 12:52:28 +0000 Subject: [PATCH 5/6] Add OAuth support for bitbucket pull-request action and update bitbucket sample template Signed-off-by: Jake Smith --- .../src/actions/bitbucketCloudPullRequest.ts | 13 ++ .../bitbucket-demo/template.yaml | 166 ++++++++++-------- 2 files changed, 106 insertions(+), 73 deletions(-) diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts index 08e19f89bf..40dd09d386 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts @@ -28,6 +28,7 @@ import { import { Config } from '@backstage/config'; import fs from 'fs-extra'; import { getAuthorizationHeader } from './helpers'; +import { getBitbucketCloudOAuthToken } from '@backstage/integration'; import { examples } from './bitbucketCloudPullRequest.examples'; const createPullRequest = async (opts: { @@ -365,6 +366,18 @@ export function createPublishBitbucketCloudPullRequestAction(options: { username: 'x-token-auth', password: ctx.input.token, }; + } else if ( + integrationConfig.config.clientId && + integrationConfig.config.clientSecret + ) { + const token = await getBitbucketCloudOAuthToken( + integrationConfig.config.clientId, + integrationConfig.config.clientSecret, + ); + auth = { + username: 'x-token-auth', + password: token, + }; } else { if ( !integrationConfig.config.username || diff --git a/plugins/scaffolder-backend/sample-templates/bitbucket-demo/template.yaml b/plugins/scaffolder-backend/sample-templates/bitbucket-demo/template.yaml index 0a7c841d8f..a1eb784249 100644 --- a/plugins/scaffolder-backend/sample-templates/bitbucket-demo/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/bitbucket-demo/template.yaml @@ -1,3 +1,4 @@ +--- apiVersion: scaffolder.backstage.io/v1beta3 kind: Template metadata: @@ -9,82 +10,101 @@ spec: type: service parameters: - - title: Choose a location - required: - - repoUrl - properties: - repoUrl: - title: Repository Location - type: string - ui:field: RepoUrlPicker - ui:options: - allowedHosts: - - bitbucket.org - # The rest of these options are optional. - # You can read more at: https://backstage.io/docs/reference/plugin-scaffolder.repourlpickerfieldextension - # allowedOwners: - # - WORKSPACE1 - # - WORKSPACE2 - # allowedProjects: - # - PROJECT1 - # - PROJECT2 - # allowedRepos: - # - REPO1 - # - REPO2 - - title: Fill in some steps - required: - - name - - owner - properties: - name: - title: Name - type: string - description: Unique name of the component - ui:autofocus: true - ui:options: - rows: 5 - owner: - title: Owner - type: string - description: Owner of the component - ui:field: OwnerPicker - ui:options: - catalogFilter: - kind: Group + - title: Choose a location + required: + - repoUrl + properties: + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - bitbucket.org + # The rest of these options are optional. + # You can read more at: https://backstage.io/docs/reference/plugin-scaffolder.repourlpickerfieldextension + # allowedOwners: + # - WORKSPACE1 + # - WORKSPACE2 + # allowedProjects: + # - PROJECT1 + # - PROJECT2 + # allowedRepos: + # - REPO1 + # - REPO2 + - title: Fill in some steps + required: + - name + - owner + properties: + name: + title: Name + type: string + description: Unique name of the component + ui:autofocus: true + ui:options: + rows: 5 + owner: + title: Owner + type: string + description: Owner of the component + ui:field: OwnerPicker + ui:options: + catalogFilter: + kind: Group steps: - - id: fetch-base - name: Fetch Base - action: fetch:template - input: - url: ./template - values: - name: ${{ parameters.name }} - owner: ${{ parameters.owner }} - - id: fetch-docs - name: Fetch Docs - action: fetch:plain - input: - targetPath: ./community - url: https://github.com/backstage/community/tree/main/backstage-community-sessions - - id: publish - name: Publish - action: publish:bitbucket - input: - description: This is ${{ parameters.name }} - repoUrl: ${{ parameters.repoUrl }} + - id: fetch-base + name: Fetch Base + action: fetch:template + input: + url: ./template + values: + name: ${{ parameters.name }} + owner: ${{ parameters.owner }} + - id: fetch-docs + name: Fetch Docs + action: fetch:plain + input: + targetPath: ./community + url: https://github.com/backstage/community/tree/main/backstage-community-sessions + - id: publish + name: Publish + action: publish:bitbucketCloud + input: + description: This is ${{ parameters.name }} + repoUrl: ${{ parameters.repoUrl }} - - id: register - name: Register - action: catalog:register - input: - repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }} - catalogInfoPath: '/catalog-info.yaml' + - id: register + name: Register + action: catalog:register + input: + repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }} + catalogInfoPath: '/catalog-info.yaml' + + - id: create-pr-file + name: Create PR File + action: fetch:plain + input: + targetPath: ./pr-demo + url: https://github.com/backstage/community/blob/main/README.md + + - id: create-pr + name: Create Pull Request + action: publish:bitbucketCloud:pull-request + input: + repoUrl: ${{ parameters.repoUrl }} + title: Test PR for ${{ parameters.name }} + description: This is a test pull request created by the scaffolder template to test OAuth support + sourceBranch: scaffolder-pr-${{ parameters.name }} + targetBranch: master output: links: - - title: Repository - url: ${{ steps['publish'].output.remoteUrl }} - - title: Open in catalog - icon: catalog - entityRef: ${{ steps['register'].output.entityRef }} + - title: Repository + url: ${{ steps['publish'].output.remoteUrl }} + - title: Pull Request + url: ${{ steps['create-pr'].output.pullRequestUrl }} + - title: Open in catalog + icon: catalog + entityRef: ${{ steps['register'].output.entityRef }} From 58b6b933feafe27505617dbcebea724a13de1321 Mon Sep 17 00:00:00 2001 From: Jake Smith Date: Sun, 23 Nov 2025 17:15:11 +0000 Subject: [PATCH 6/6] Restore original yaml formatting Signed-off-by: Jake Smith --- .../bitbucket-demo/template.yaml | 182 +++++++++--------- 1 file changed, 90 insertions(+), 92 deletions(-) diff --git a/plugins/scaffolder-backend/sample-templates/bitbucket-demo/template.yaml b/plugins/scaffolder-backend/sample-templates/bitbucket-demo/template.yaml index a1eb784249..c8f85974ea 100644 --- a/plugins/scaffolder-backend/sample-templates/bitbucket-demo/template.yaml +++ b/plugins/scaffolder-backend/sample-templates/bitbucket-demo/template.yaml @@ -1,4 +1,3 @@ ---- apiVersion: scaffolder.backstage.io/v1beta3 kind: Template metadata: @@ -10,101 +9,100 @@ spec: type: service parameters: - - title: Choose a location - required: - - repoUrl - properties: - repoUrl: - title: Repository Location - type: string - ui:field: RepoUrlPicker - ui:options: - allowedHosts: - - bitbucket.org - # The rest of these options are optional. - # You can read more at: https://backstage.io/docs/reference/plugin-scaffolder.repourlpickerfieldextension - # allowedOwners: - # - WORKSPACE1 - # - WORKSPACE2 - # allowedProjects: - # - PROJECT1 - # - PROJECT2 - # allowedRepos: - # - REPO1 - # - REPO2 - - title: Fill in some steps - required: - - name - - owner - properties: - name: - title: Name - type: string - description: Unique name of the component - ui:autofocus: true - ui:options: - rows: 5 - owner: - title: Owner - type: string - description: Owner of the component - ui:field: OwnerPicker - ui:options: - catalogFilter: - kind: Group + - title: Choose a location + required: + - repoUrl + properties: + repoUrl: + title: Repository Location + type: string + ui:field: RepoUrlPicker + ui:options: + allowedHosts: + - bitbucket.org + # The rest of these options are optional. + # You can read more at: https://backstage.io/docs/reference/plugin-scaffolder.repourlpickerfieldextension + # allowedOwners: + # - WORKSPACE1 + # - WORKSPACE2 + # allowedProjects: + # - PROJECT1 + # - PROJECT2 + # allowedRepos: + # - REPO1 + # - REPO2 + - title: Fill in some steps + required: + - name + - owner + properties: + name: + title: Name + type: string + description: Unique name of the component + ui:autofocus: true + ui:options: + rows: 5 + owner: + title: Owner + type: string + description: Owner of the component + ui:field: OwnerPicker + ui:options: + catalogFilter: + kind: Group steps: - - id: fetch-base - name: Fetch Base - action: fetch:template - input: - url: ./template - values: - name: ${{ parameters.name }} - owner: ${{ parameters.owner }} - - id: fetch-docs - name: Fetch Docs - action: fetch:plain - input: - targetPath: ./community - url: https://github.com/backstage/community/tree/main/backstage-community-sessions - - id: publish - name: Publish - action: publish:bitbucketCloud - input: - description: This is ${{ parameters.name }} - repoUrl: ${{ parameters.repoUrl }} + - id: fetch-base + name: Fetch Base + action: fetch:template + input: + url: ./template + values: + name: ${{ parameters.name }} + owner: ${{ parameters.owner }} + - id: fetch-docs + name: Fetch Docs + action: fetch:plain + input: + targetPath: ./community + url: https://github.com/backstage/community/tree/main/backstage-community-sessions + - id: publish + name: Publish + action: publish:bitbucketCloud + input: + description: This is ${{ parameters.name }} + repoUrl: ${{ parameters.repoUrl }} - - id: register - name: Register - action: catalog:register - input: - repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }} - catalogInfoPath: '/catalog-info.yaml' + - id: register + name: Register + action: catalog:register + input: + repoContentsUrl: ${{ steps['publish'].output.repoContentsUrl }} + catalogInfoPath: '/catalog-info.yaml' - - id: create-pr-file - name: Create PR File - action: fetch:plain - input: - targetPath: ./pr-demo - url: https://github.com/backstage/community/blob/main/README.md - - - id: create-pr - name: Create Pull Request - action: publish:bitbucketCloud:pull-request - input: - repoUrl: ${{ parameters.repoUrl }} - title: Test PR for ${{ parameters.name }} - description: This is a test pull request created by the scaffolder template to test OAuth support - sourceBranch: scaffolder-pr-${{ parameters.name }} - targetBranch: master + - id: create-pr-file + name: Create PR File + action: fetch:plain + input: + targetPath: ./pr-demo + url: https://github.com/backstage/community/blob/main/README.md + - id: create-pr + name: Create Pull Request + action: publish:bitbucketCloud:pull-request + input: + repoUrl: ${{ parameters.repoUrl }} + title: Test PR for ${{ parameters.name }} + description: This is a test pull request created by the scaffolder template to test OAuth support + sourceBranch: scaffolder-pr-${{ parameters.name }} + targetBranch: master output: links: - - title: Repository - url: ${{ steps['publish'].output.remoteUrl }} - - title: Pull Request - url: ${{ steps['create-pr'].output.pullRequestUrl }} - - title: Open in catalog - icon: catalog - entityRef: ${{ steps['register'].output.entityRef }} + - title: Repository + url: ${{ steps['publish'].output.remoteUrl }} + - title: Pull Request + url: ${{ steps['create-pr'].output.pullRequestUrl }} + - title: Open in catalog + icon: catalog + entityRef: ${{ steps['register'].output.entityRef }}