diff --git a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucketServerPullRequest.ts b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucketServerPullRequest.ts index dc75145ebc..66327d4f6c 100644 --- a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucketServerPullRequest.ts +++ b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucketServerPullRequest.ts @@ -21,6 +21,8 @@ import { } from '@backstage/integration'; import { createTemplateAction, + getRepoSourceDirectory, + commitAndPushBranch, parseRepoUrl, } from '@backstage/plugin-scaffolder-node'; import fetch, { RequestInit, Response } from 'node-fetch'; @@ -152,7 +154,51 @@ const findBranches = async (opts: { 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(); +}; /** * Creates a BitbucketServer Pull Request action. * @public @@ -161,7 +207,7 @@ export function createPublishBitbucketServerPullRequestAction(options: { integrations: ScmIntegrationRegistry; config: Config; }) { - const { integrations } = options; + const { integrations, config } = options; return createTemplateAction<{ repoUrl: string; @@ -268,7 +314,7 @@ export function createPublishBitbucketServerPullRequestAction(options: { apiBaseUrl, }); - const fromRef = await findBranches({ + let fromRef = await findBranches({ project, repo, branchName: sourceBranch, @@ -276,6 +322,53 @@ export function createPublishBitbucketServerPullRequestAction(options: { 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: config.getOptionalString('scaffolder.defaultAuthor.name'), + email: config.getOptionalString('scaffolder.defaultAuthor.email'), + }; + + await commitAndPushBranch({ + tempDir: await ctx.createTemporaryDirectory(), + dir: getRepoSourceDirectory(ctx.workspacePath, undefined), + remoteUrl, + auth, + logger: ctx.logger, + commitMessage: + description ?? + config.getOptionalString('scaffolder.defaultCommitMessage') ?? + '', + gitAuthorInfo, + branch: sourceBranch, + }); + } + const pullRequestUrl = await createPullRequest({ project, repo, diff --git a/plugins/scaffolder-node/src/actions/gitHelpers.test.ts b/plugins/scaffolder-node/src/actions/gitHelpers.test.ts index 7bc3f64f46..2d2220f33d 100644 --- a/plugins/scaffolder-node/src/actions/gitHelpers.test.ts +++ b/plugins/scaffolder-node/src/actions/gitHelpers.test.ts @@ -15,7 +15,11 @@ */ import { Git, getVoidLogger } from '@backstage/backend-common'; -import { commitAndPushRepo, initRepoAndPush } from './gitHelpers'; +import { + commitAndPushRepo, + initRepoAndPush, + commitAndPushBranch, +} from './gitHelpers'; jest.mock('@backstage/backend-common', () => ({ Git: { @@ -29,10 +33,14 @@ jest.mock('@backstage/backend-common', () => ({ fetch: jest.fn(), addRemote: jest.fn(), push: jest.fn(), + clone: jest.fn(), }), }, getVoidLogger: jest.requireActual('@backstage/backend-common').getVoidLogger, })); +jest.mock('fs-extra', () => ({ + cpSync: jest.fn(), +})); const mockedGit = Git.fromAuth({ logger: getVoidLogger(), @@ -303,3 +311,142 @@ describe('commitAndPushRepo', () => { }); }); }); + +describe('commitAndPushBranch', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('with minimal parameters', () => { + beforeEach(async () => { + await commitAndPushBranch({ + tempDir: '/tmp/repo/dir/', + dir: '/test/repo/dir/', + remoteUrl: 'git@github.com:test/repo.git', + auth: { + username: 'test-user', + password: 'test-password', + }, + commitMessage: 'commit message', + gitAuthorInfo: { + name: 'Scaffolder', + email: 'scaffolder@backstage.io', + }, + logger: getVoidLogger(), + }); + }); + + it('clone the repo', () => { + expect(mockedGit.clone).toHaveBeenCalledWith({ + url: 'git@github.com:test/repo.git', + dir: '/tmp/repo/dir/', + }); + }); + + it('fetch the branches', () => { + expect(mockedGit.fetch).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + }); + }); + + it('checkout the branch', () => { + expect(mockedGit.checkout).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + ref: 'master', + }); + }); + + it('stages all files in the repo', () => { + expect(mockedGit.add).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + filepath: '.', + }); + }); + + it('creates an commit', () => { + expect(mockedGit.commit).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + message: 'commit message', + author: { + name: 'Scaffolder', + email: 'scaffolder@backstage.io', + }, + committer: { + name: 'Scaffolder', + email: 'scaffolder@backstage.io', + }, + }); + }); + + it('pushes to the remote', () => { + expect(mockedGit.push).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + remote: 'origin', + remoteRef: 'refs/heads/master', + }); + }); + }); + + it('with token', async () => { + await commitAndPushBranch({ + tempDir: '/tmp/repo/dir/', + dir: '/test/repo/dir/', + remoteUrl: 'git@github.com:test/repo.git', + auth: { + token: 'test-token', + }, + commitMessage: 'commit message', + logger: getVoidLogger(), + }); + + expect(mockedGit.clone).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + url: 'git@github.com:test/repo.git', + }); + }); + + it('allows overriding the default branch', async () => { + await commitAndPushBranch({ + tempDir: '/tmp/repo/dir/', + dir: '/test/repo/dir/', + branch: 'trunk', + remoteUrl: 'git@github.com:test/repo.git', + auth: { + username: 'test-user', + password: 'test-password', + }, + commitMessage: 'commit message', + logger: getVoidLogger(), + }); + + expect(mockedGit.checkout).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + ref: 'trunk', + }); + }); + + it('allows overriding the remoteRef', async () => { + await commitAndPushBranch({ + tempDir: '/tmp/repo/dir/', + dir: '/test/repo/dir/', + gitAuthorInfo: { + name: 'Custom Scaffolder Author', + email: 'scaffolder@example.org', + }, + remoteUrl: 'git@github.com:test/repo.git', + auth: { + username: 'test-user', + password: 'test-password', + }, + remoteRef: 'ABC123', + commitMessage: 'commit message', + logger: getVoidLogger(), + }); + + expect(mockedGit.push).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + remote: 'origin', + remoteRef: 'ABC123', + }); + }); +}); diff --git a/plugins/scaffolder-node/src/actions/gitHelpers.ts b/plugins/scaffolder-node/src/actions/gitHelpers.ts index 6d449a0a29..f2c88872ad 100644 --- a/plugins/scaffolder-node/src/actions/gitHelpers.ts +++ b/plugins/scaffolder-node/src/actions/gitHelpers.ts @@ -16,6 +16,7 @@ import { Git } from '@backstage/backend-common'; import { Logger } from 'winston'; +import fs from 'fs-extra'; /** * @public @@ -134,3 +135,68 @@ export async function commitAndPushRepo(input: { return { commitHash }; } + +export async function commitAndPushBranch({ + tempDir, + dir, + remoteUrl, + auth, + logger, + commitMessage, + gitAuthorInfo, + branch = 'master', + remoteRef, +}: { + tempDir: string; + dir: string; + remoteUrl: string; + // For use cases where token has to be used with Basic Auth + // it has to be provided as password together with a username + // which may be a fixed value defined by the provider. + auth: { username: string; password: string } | { token: string }; + logger: Logger; + commitMessage: string; + gitAuthorInfo?: { name?: string; email?: string }; + branch?: string; + remoteRef?: string; +}): Promise<{ commitHash: string }> { + const git = Git.fromAuth({ + ...auth, + logger, + }); + + await git.clone({ url: remoteUrl, dir: tempDir }); + await git.fetch({ dir: tempDir }); + await git.checkout({ dir: tempDir, ref: branch }); + + // copy files + fs.cpSync(dir, tempDir, { + recursive: true, + filter: path => { + return !(path.indexOf('.git') > -1); + }, + }); + + await git.add({ dir: tempDir, filepath: '.' }); + + // use provided info if possible, otherwise use fallbacks + const authorInfo = { + name: gitAuthorInfo?.name ?? 'Scaffolder', + email: gitAuthorInfo?.email ?? 'scaffolder@backstage.io', + }; + + const commitHash = await git.commit({ + dir: tempDir, + message: commitMessage, + author: authorInfo, + committer: authorInfo, + }); + + await git.push({ + dir: tempDir, + remote: 'origin', + remoteRef: remoteRef ?? `refs/heads/${branch}`, + }); + + return { commitHash }; +} diff --git a/plugins/scaffolder-node/src/actions/index.ts b/plugins/scaffolder-node/src/actions/index.ts index 74a0bb61f9..885f34c293 100644 --- a/plugins/scaffolder-node/src/actions/index.ts +++ b/plugins/scaffolder-node/src/actions/index.ts @@ -25,5 +25,9 @@ export { } from './executeShellCommand'; export { fetchContents, fetchFile } from './fetch'; export { type ActionContext, type TemplateAction } from './types'; -export { initRepoAndPush, commitAndPushRepo } from './gitHelpers'; +export { + initRepoAndPush, + commitAndPushRepo, + commitAndPushBranch, +} from './gitHelpers'; export { parseRepoUrl, getRepoSourceDirectory } from './util';