From bffd11fb40872dd73293f28d9562844df5af173a Mon Sep 17 00:00:00 2001 From: Benjamin Janssens Date: Tue, 3 Sep 2024 16:28:19 +0200 Subject: [PATCH] feat: started porting the publish:bitbucketServer:pull-request action to Bitbucket Cloud Signed-off-by: Benjamin Janssens --- .../src/actions/bitbucketCloudPullRequest.ts | 468 ++++++++++++++++++ .../src/actions/index.ts | 1 + .../src/module.ts | 5 + .../actions/builtin/createBuiltinActions.ts | 2 + 4 files changed, 476 insertions(+) create mode 100644 plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts new file mode 100644 index 0000000000..b5cce9d0a4 --- /dev/null +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/bitbucketCloudPullRequest.ts @@ -0,0 +1,468 @@ +/* + * Copyright 2024 The Backstage Authors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { InputError } from '@backstage/errors'; +import { + getBitbucketServerRequestOptions, + ScmIntegrationRegistry, +} from '@backstage/integration'; +import { + createTemplateAction, + getRepoSourceDirectory, + commitAndPushBranch, + addFiles, + createBranch as createGitBranch, + cloneRepo, + parseRepoUrl, +} from '@backstage/plugin-scaffolder-node'; +import fetch, { RequestInit, Response } from 'node-fetch'; +import { Config } from '@backstage/config'; +import fs from 'fs-extra'; +import { getAuthorizationHeader } from './helpers'; + +const createPullRequest = async (opts: { + workspace: string; + repo: string; + title: string; + description?: string; + targetBranch: string; + sourceBranch: string; + authorization: string; + apiBaseUrl: string; +}) => { + const { + workspace, + repo, + title, + description, + targetBranch, + sourceBranch, + authorization, + apiBaseUrl, + } = opts; + + let response: Response; + const data: RequestInit = { + method: 'POST', + body: JSON.stringify({ + title: title, + summary: { + raw: description, + }, + state: 'OPEN', + source: { + branch: { + name: sourceBranch, + }, + }, + destination: { + branch: { + name: targetBranch, + }, + }, + }), + headers: { + Authorization: authorization, + 'Content-Type': 'application/json', + }, + }; + + try { + response = await fetch( + `${apiBaseUrl}/repositories/${workspace}/${repo}/pullrequests`, + data, + ); + } catch (e) { + throw new Error(`Unable to create pull-reqeusts, ${e}`); + } + + if (response.status !== 201) { + throw new Error( + `Unable to create pull requests, ${response.status} ${ + response.statusText + }, ${await response.text()}`, + ); + } + + const r = await response.json(); + return r.links.self.href; +}; + +const findBranches = async (opts: { + project: string; + repo: string; + branchName: string; + authorization: string; + apiBaseUrl: string; +}) => { + const { project, repo, branchName, authorization, apiBaseUrl } = opts; + + let response: Response; + const options: RequestInit = { + method: 'GET', + headers: { + Authorization: authorization, + 'Content-Type': 'application/json', + }, + }; + + try { + response = await fetch( + `${apiBaseUrl}/projects/${encodeURIComponent( + project, + )}/repos/${encodeURIComponent( + repo, + )}/branches?boostMatches=true&filterText=${encodeURIComponent( + branchName, + )}`, + options, + ); + } catch (e) { + throw new Error(`Unable to get branches, ${e}`); + } + + if (response.status !== 200) { + throw new Error( + `Unable to get branches, ${response.status} ${ + response.statusText + }, ${await response.text()}`, + ); + } + + const r = await response.json(); + for (const object of r.values) { + if (object.displayId === branchName) { + return object; + } + } + + return undefined; +}; +const createBranch = async (opts: { + project: string; + repo: string; + branchName: string; + authorization: string; + apiBaseUrl: string; + startPoint: string; +}) => { + const { project, repo, branchName, authorization, apiBaseUrl, startPoint } = + opts; + + let response: Response; + const options: RequestInit = { + method: 'POST', + body: JSON.stringify({ + name: branchName, + startPoint, + }), + headers: { + Authorization: authorization, + 'Content-Type': 'application/json', + }, + }; + + try { + response = await fetch( + `${apiBaseUrl}/projects/${encodeURIComponent( + project, + )}/repos/${encodeURIComponent(repo)}/branches`, + options, + ); + } catch (e) { + throw new Error(`Unable to create branch, ${e}`); + } + + if (response.status !== 200) { + throw new Error( + `Unable to create branch, ${response.status} ${ + response.statusText + }, ${await response.text()}`, + ); + } + + return await response.json(); +}; +const getDefaultBranch = async (opts: { + workspace: string; + repo: string; + authorization: string; + apiBaseUrl: string; +}): Promise => { + const { workspace, repo, authorization, apiBaseUrl } = opts; + let response: Response; + + const options: RequestInit = { + method: 'GET', + headers: { + Authorization: authorization, + 'Content-Type': 'application/json', + }, + }; + + try { + response = await fetch( + `${apiBaseUrl}/repositories/${workspace}/${repo}`, + options, + ); + } catch (error) { + throw error; + } + + const { mainbranch } = await response.json(); + console.log(authorization); + const defaultBranch = mainbranch.name; + if (!defaultBranch) { + throw new Error(`Could not fetch default branch for ${workspace}/${repo}`); + } + return defaultBranch; +}; +/** + * Creates a Bitbucket Cloud Pull Request action. + * @public + */ +export function createPublishBitbucketCloudPullRequestAction(options: { + integrations: ScmIntegrationRegistry; + config: Config; +}) { + const { integrations, config } = options; + + return createTemplateAction<{ + repoUrl: string; + title: string; + description?: string; + targetBranch?: string; + sourceBranch: string; + token?: string; + gitAuthorName?: string; + gitAuthorEmail?: string; + }>({ + id: 'publish:bitbucketCloud:pull-request', + // examples, + schema: { + input: { + type: 'object', + required: ['repoUrl', 'title', 'sourceBranch'], + properties: { + repoUrl: { + title: 'Repository Location', + type: 'string', + }, + title: { + title: 'Pull Request title', + type: 'string', + description: 'The title for the pull request', + }, + description: { + title: 'Pull Request Description', + type: 'string', + description: 'The description of the pull request', + }, + targetBranch: { + title: 'Target Branch', + type: 'string', + description: `Branch of repository to apply changes to. The default value is 'master'`, + }, + sourceBranch: { + title: 'Source Branch', + type: 'string', + description: 'Branch of repository to copy changes from', + }, + token: { + title: 'Authorization Token', + type: 'string', + description: + 'The token to use for authorization to BitBucket Cloud', + }, + gitAuthorName: { + title: 'Author Name', + type: 'string', + description: `Sets the author name for the commit. The default value is 'Scaffolder'`, + }, + gitAuthorEmail: { + title: 'Author Email', + type: 'string', + description: `Sets the author email for the commit.`, + }, + }, + }, + output: { + type: 'object', + properties: { + pullRequestUrl: { + title: 'A URL to the pull request with the provider', + type: 'string', + }, + }, + }, + }, + async handler(ctx) { + const { + repoUrl, + title, + description, + targetBranch, + sourceBranch, + gitAuthorName, + gitAuthorEmail, + } = ctx.input; + + const { project, repo, host, workspace } = parseRepoUrl( + repoUrl, + integrations, + ); + + if (!project) { + throw new InputError( + `Invalid URL provider was included in the repo URL to create ${ctx.input.repoUrl}, missing project`, + ); + } + + const integrationConfig = integrations.bitbucketCloud.byHost(host); + if (!integrationConfig) { + throw new InputError( + `No matching integration configuration for host ${host}, please check your integrations config`, + ); + } + + const authorization = getAuthorizationHeader( + ctx.input.token ? { token: ctx.input.token } : integrationConfig.config, + ); + + const apiBaseUrl = integrationConfig.config.apiBaseUrl; + + let finalTargetBranch = targetBranch; + if (!finalTargetBranch) { + finalTargetBranch = await getDefaultBranch({ + workspace: workspace!, + repo, + authorization, + apiBaseUrl, + }); + } + + // const toRef = await findBranches({ + // project, + // repo, + // branchName: finalTargetBranch!, + // authorization, + // apiBaseUrl, + // }); + + // let fromRef = await findBranches({ + // project, + // repo, + // branchName: sourceBranch, + // authorization, + // apiBaseUrl, + // }); + + // if (!fromRef) { + // // create branch + // ctx.logger.info( + // `source branch not found -> creating branch named: ${sourceBranch} lastCommit: ${toRef.latestCommit}`, + // ); + // const latestCommit = toRef.latestCommit; + + // fromRef = await createBranch({ + // project, + // repo, + // branchName: sourceBranch, + // authorization, + // apiBaseUrl, + // startPoint: latestCommit, + // }); + + // const remoteUrl = `https://${host}/scm/${project}/${repo}.git`; + + // const auth = authConfig.token + // ? { + // token: token!, + // } + // : { + // username: authConfig.username!, + // password: authConfig.password!, + // }; + + // const gitAuthorInfo = { + // name: + // gitAuthorName || + // config.getOptionalString('scaffolder.defaultAuthor.name'), + // email: + // gitAuthorEmail || + // config.getOptionalString('scaffolder.defaultAuthor.email'), + // }; + + // const tempDir = await ctx.createTemporaryDirectory(); + // const sourceDir = getRepoSourceDirectory(ctx.workspacePath, undefined); + // await cloneRepo({ + // url: remoteUrl, + // dir: tempDir, + // auth, + // logger: ctx.logger, + // ref: sourceBranch, + // }); + + // await createGitBranch({ + // dir: tempDir, + // auth, + // logger: ctx.logger, + // ref: sourceBranch, + // }); + + // // copy files + // fs.cpSync(sourceDir, tempDir, { + // recursive: true, + // filter: path => { + // return !(path.indexOf('.git') > -1); + // }, + // }); + + // await addFiles({ + // dir: tempDir, + // auth, + // logger: ctx.logger, + // filepath: '.', + // }); + + // await commitAndPushBranch({ + // dir: tempDir, + // auth, + // logger: ctx.logger, + // commitMessage: + // description ?? + // config.getOptionalString('scaffolder.defaultCommitMessage') ?? + // '', + // gitAuthorInfo, + // branch: sourceBranch, + // }); + // } + + const pullRequestUrl = await createPullRequest({ + workspace: workspace!, + repo, + title, + description, + targetBranch: finalTargetBranch, + sourceBranch, + authorization, + apiBaseUrl, + }); + + ctx.output('pullRequestUrl', pullRequestUrl); + }, + }); +} 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 a9826a268a..c4030c0b52 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/index.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/actions/index.ts @@ -15,3 +15,4 @@ */ export * from './bitbucketCloud'; export { createBitbucketPipelinesRunAction } from './bitbucketCloudPipelinesRun'; +export * from './bitbucketCloudPullRequest'; diff --git a/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts b/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts index 5bf49f7cd9..c0f922ca08 100644 --- a/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts +++ b/plugins/scaffolder-backend-module-bitbucket-cloud/src/module.ts @@ -24,6 +24,7 @@ import { import { createBitbucketPipelinesRunAction, createPublishBitbucketCloudAction, + createPublishBitbucketCloudPullRequestAction, } from './actions'; import { ScmIntegrations } from '@backstage/integration'; import { handleAutocompleteRequest } from './autocomplete/autocomplete'; @@ -48,6 +49,10 @@ export const bitbucketCloudModule = createBackendModule({ scaffolder.addActions( createPublishBitbucketCloudAction({ integrations, config }), createBitbucketPipelinesRunAction({ integrations }), + createPublishBitbucketCloudPullRequestAction({ + integrations, + config, + }), ); autocomplete.addAutocompleteProvider({ diff --git a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts index dba867c96b..e2392150cc 100644 --- a/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts +++ b/plugins/scaffolder-backend/src/scaffolder/actions/builtin/createBuiltinActions.ts @@ -62,6 +62,7 @@ import { createPublishBitbucketAction } from '@backstage/plugin-scaffolder-backe import { createPublishBitbucketCloudAction, createBitbucketPipelinesRunAction, + createPublishBitbucketCloudPullRequestAction, } from '@backstage/plugin-scaffolder-backend-module-bitbucket-cloud'; import { @@ -197,6 +198,7 @@ export const createBuiltinActions = ( integrations, config, }), + createPublishBitbucketCloudPullRequestAction({ integrations, config }), createPublishBitbucketServerAction({ integrations, config,