diff --git a/.changeset/nine-olives-swim.md b/.changeset/nine-olives-swim.md index 057629a8c3..40852c08c2 100644 --- a/.changeset/nine-olives-swim.md +++ b/.changeset/nine-olives-swim.md @@ -2,4 +2,4 @@ '@backstage/plugin-scaffolder-node': minor --- -Added function to commitAndPushBranch which allows for files to be added to the a PR for use in the bitbucket pull request action for issue #21762 +Added functions to clone a repo, create a branch, add files and push and commit to the branch. This allows for files to be added to the a PR for use in the bitbucket pull request action for issue #21762 diff --git a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucketServerPullRequest.ts b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucketServerPullRequest.ts index 66327d4f6c..fc123f0b80 100644 --- a/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucketServerPullRequest.ts +++ b/plugins/scaffolder-backend-module-bitbucket/src/actions/bitbucketServerPullRequest.ts @@ -23,10 +23,14 @@ 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'; const createPullRequest = async (opts: { project: string; @@ -354,10 +358,40 @@ export function createPublishBitbucketServerPullRequestAction(options: { email: 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({ - tempDir: await ctx.createTemporaryDirectory(), - dir: getRepoSourceDirectory(ctx.workspacePath, undefined), - remoteUrl, + dir: tempDir, auth, logger: ctx.logger, commitMessage: diff --git a/plugins/scaffolder-node/api-report.md b/plugins/scaffolder-node/api-report.md index f3d3a7b2f2..5dff4a8e5e 100644 --- a/plugins/scaffolder-node/api-report.md +++ b/plugins/scaffolder-node/api-report.md @@ -46,20 +46,14 @@ export type ActionContext< }; // @public (undocumented) -export function commitAndPushBranch({ - tempDir, +export function addFiles({ dir, - remoteUrl, + filepath, auth, logger, - commitMessage, - gitAuthorInfo, - branch, - remoteRef, }: { - tempDir: string; dir: string; - remoteUrl: string; + filepath: string; auth: | { username: string; @@ -68,7 +62,56 @@ export function commitAndPushBranch({ | { token: string; }; - logger: Logger; + logger?: Logger | undefined; +}): Promise; + +// @public (undocumented) +export function cloneRepo({ + url, + dir, + auth, + logger, + ref, + depth, + noCheckout, +}: { + url: string; + dir: string; + auth: + | { + username: string; + password: string; + } + | { + token: string; + }; + logger?: Logger | undefined; + ref?: string | undefined; + depth?: number | undefined; + noCheckout?: boolean | undefined; +}): Promise; + +// @public (undocumented) +export function commitAndPushBranch({ + dir, + auth, + logger, + commitMessage, + gitAuthorInfo, + branch, + remoteRef, + remote, +}: { + dir: string; + auth: + | { + username: string; + password: string; + } + | { + token: string; + }; + logger?: Logger | undefined; commitMessage: string; gitAuthorInfo?: { name?: string; @@ -76,6 +119,7 @@ export function commitAndPushBranch({ }; branch?: string; remoteRef?: string; + remote?: string; }): Promise<{ commitHash: string; }>; @@ -103,6 +147,26 @@ export function commitAndPushRepo(input: { commitHash: string; }>; +// @public (undocumented) +export function createBranch({ + dir, + auth, + logger, + ref, +}: { + dir: string; + ref: string; + auth: + | { + username: string; + password: string; + } + | { + token: string; + }; + logger?: Logger | undefined; +}): Promise; + // @public export const createTemplateAction: < TInputParams extends JsonObject = JsonObject, diff --git a/plugins/scaffolder-node/src/actions/gitHelpers.test.ts b/plugins/scaffolder-node/src/actions/gitHelpers.test.ts index 2d2220f33d..b230b2cd06 100644 --- a/plugins/scaffolder-node/src/actions/gitHelpers.test.ts +++ b/plugins/scaffolder-node/src/actions/gitHelpers.test.ts @@ -19,6 +19,9 @@ import { commitAndPushRepo, initRepoAndPush, commitAndPushBranch, + addFiles, + createBranch, + cloneRepo, } from './gitHelpers'; jest.mock('@backstage/backend-common', () => ({ @@ -312,6 +315,198 @@ describe('commitAndPushRepo', () => { }); }); +describe('cloneRepo', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('with minimal parameters', () => { + beforeEach(async () => { + await cloneRepo({ + url: 'git@github.com:test/repo.git', + dir: '/tmp/repo/dir/', + auth: { + username: 'test-user', + password: 'test-password', + }, + }); + }); + + it('clone the repo', () => { + expect(mockedGit.clone).toHaveBeenCalledWith({ + url: 'git@github.com:test/repo.git', + dir: '/tmp/repo/dir/', + ref: undefined, + depth: undefined, + noCheckout: undefined, + }); + }); + }); + + it('with token', async () => { + await cloneRepo({ + url: 'git@github.com:test/repo.git', + dir: '/tmp/repo/dir/', + auth: { + token: 'test-token', + }, + }); + + expect(mockedGit.clone).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + url: 'git@github.com:test/repo.git', + ref: undefined, + depth: undefined, + noCheckout: undefined, + }); + }); + + it('allows overriding the default branch', async () => { + await cloneRepo({ + url: 'git@github.com:test/repo.git', + dir: '/tmp/repo/dir/', + ref: 'trunk', + auth: { + username: 'test-user', + password: 'test-password', + }, + }); + + expect(mockedGit.clone).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + url: 'git@github.com:test/repo.git', + ref: 'trunk', + depth: undefined, + noCheckout: undefined, + }); + }); + + it('allows overriding the depth', async () => { + await cloneRepo({ + url: 'git@github.com:test/repo.git', + dir: '/tmp/repo/dir/', + ref: 'trunk', + depth: 2, + auth: { + username: 'test-user', + password: 'test-password', + }, + }); + + expect(mockedGit.clone).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + url: 'git@github.com:test/repo.git', + ref: 'trunk', + depth: 2, + noCheckout: undefined, + }); + }); + + it('allows overriding the noCheckout', async () => { + await cloneRepo({ + url: 'git@github.com:test/repo.git', + dir: '/tmp/repo/dir/', + ref: 'trunk', + depth: 2, + noCheckout: true, + auth: { + username: 'test-user', + password: 'test-password', + }, + }); + + expect(mockedGit.clone).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + url: 'git@github.com:test/repo.git', + ref: 'trunk', + depth: 2, + noCheckout: true, + }); + }); +}); + +describe('createBranch', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('with minimal parameters', () => { + beforeEach(async () => { + await createBranch({ + dir: '/tmp/repo/dir/', + ref: 'trunk', + auth: { + username: 'test-user', + password: 'test-password', + }, + }); + }); + + it('create the branch', () => { + expect(mockedGit.checkout).toHaveBeenCalledWith({ + ref: 'trunk', + dir: '/tmp/repo/dir/', + }); + }); + }); + + it('with token', async () => { + await createBranch({ + dir: '/tmp/repo/dir/', + ref: 'trunk', + auth: { + token: 'test-token', + }, + }); + + expect(mockedGit.checkout).toHaveBeenCalledWith({ + ref: 'trunk', + dir: '/tmp/repo/dir/', + }); + }); +}); + +describe('addFiles', () => { + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('with minimal parameters', () => { + beforeEach(async () => { + await addFiles({ + dir: '/tmp/repo/dir/', + filepath: '.', + auth: { + username: 'test-user', + password: 'test-password', + }, + }); + }); + + it('add files', () => { + expect(mockedGit.add).toHaveBeenCalledWith({ + filepath: '.', + dir: '/tmp/repo/dir/', + }); + }); + }); + + it('with token', async () => { + await addFiles({ + dir: '/tmp/repo/dir/', + filepath: '.', + auth: { + token: 'test-token', + }, + }); + + expect(mockedGit.add).toHaveBeenCalledWith({ + filepath: '.', + dir: '/tmp/repo/dir/', + }); + }); +}); + describe('commitAndPushBranch', () => { afterEach(() => { jest.clearAllMocks(); @@ -320,53 +515,19 @@ describe('commitAndPushBranch', () => { describe('with minimal parameters', () => { beforeEach(async () => { await commitAndPushBranch({ - tempDir: '/tmp/repo/dir/', - dir: '/test/repo/dir/', - remoteUrl: 'git@github.com:test/repo.git', + dir: '/tmp/repo/dir/', 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', () => { + it('create commit', () => { expect(mockedGit.commit).toHaveBeenCalledWith({ - dir: '/tmp/repo/dir/', message: 'commit message', + dir: '/tmp/repo/dir/', author: { name: 'Scaffolder', email: 'scaffolder@backstage.io', @@ -389,58 +550,91 @@ describe('commitAndPushBranch', () => { it('with token', async () => { await commitAndPushBranch({ - tempDir: '/tmp/repo/dir/', - dir: '/test/repo/dir/', - remoteUrl: 'git@github.com:test/repo.git', + dir: '/tmp/repo/dir/', auth: { token: 'test-token', }, commitMessage: 'commit message', + gitAuthorInfo: { + name: 'gitCommitter', + email: 'gitCommitter@backstage.io', + }, logger: getVoidLogger(), }); - expect(mockedGit.clone).toHaveBeenCalledWith({ + expect(mockedGit.commit).toHaveBeenCalledWith({ + message: 'commit message', dir: '/tmp/repo/dir/', - url: 'git@github.com:test/repo.git', + author: { + name: 'gitCommitter', + email: 'gitCommitter@backstage.io', + }, + committer: { + name: 'gitCommitter', + email: 'gitCommitter@backstage.io', + }, + }); + + expect(mockedGit.push).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + remote: 'origin', + remoteRef: 'refs/heads/master', }); }); 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', + dir: '/tmp/repo/dir/', auth: { username: 'test-user', password: 'test-password', }, commitMessage: 'commit message', - logger: getVoidLogger(), + branch: 'trunk', }); - expect(mockedGit.checkout).toHaveBeenCalledWith({ + expect(mockedGit.commit).toHaveBeenCalledWith({ + message: 'commit message', dir: '/tmp/repo/dir/', - ref: 'trunk', + author: { + name: 'Scaffolder', + email: 'scaffolder@backstage.io', + }, + committer: { + name: 'Scaffolder', + email: 'scaffolder@backstage.io', + }, + }); + + expect(mockedGit.push).toHaveBeenCalledWith({ + dir: '/tmp/repo/dir/', + remote: 'origin', + remoteRef: 'refs/heads/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', + dir: '/tmp/repo/dir/', auth: { username: 'test-user', password: 'test-password', }, - remoteRef: 'ABC123', commitMessage: 'commit message', - logger: getVoidLogger(), + remoteRef: 'ABC123', + }); + + expect(mockedGit.commit).toHaveBeenCalledWith({ + message: 'commit message', + dir: '/tmp/repo/dir/', + author: { + name: 'Scaffolder', + email: 'scaffolder@backstage.io', + }, + committer: { + name: 'Scaffolder', + email: 'scaffolder@backstage.io', + }, }); expect(mockedGit.push).toHaveBeenCalledWith({ diff --git a/plugins/scaffolder-node/src/actions/gitHelpers.ts b/plugins/scaffolder-node/src/actions/gitHelpers.ts index 45a7761c0a..7f36ae0137 100644 --- a/plugins/scaffolder-node/src/actions/gitHelpers.ts +++ b/plugins/scaffolder-node/src/actions/gitHelpers.ts @@ -16,7 +16,6 @@ import { Git } from '@backstage/backend-common'; import { Logger } from 'winston'; -import fs from 'fs-extra'; /** * @public @@ -139,49 +138,114 @@ export async function commitAndPushRepo(input: { /** * @public */ -export async function commitAndPushBranch({ - tempDir, +export async function cloneRepo({ + url, + dir, + auth, + logger, + ref, + depth, + noCheckout, +}: { + url: string; + dir: 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 | undefined; + ref?: string | undefined; + depth?: number | undefined; + noCheckout?: boolean | undefined; +}): Promise { + const git = Git.fromAuth({ + ...auth, + logger, + }); + + await git.clone({ url, dir, ref, depth, noCheckout }); +} + +/** + * @public + */ +export async function createBranch({ + dir, + auth, + logger, + ref, +}: { + dir: string; + ref: 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 | undefined; +}): Promise { + const git = Git.fromAuth({ + ...auth, + logger, + }); + + await git.checkout({ dir, ref }); +} + +/** + * @public + */ +export async function addFiles({ + dir, + filepath, + auth, + logger, +}: { + dir: string; + filepath: 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 | undefined; +}): Promise { + const git = Git.fromAuth({ + ...auth, + logger, + }); + + await git.add({ dir, filepath }); +} + +/** + * @public + */ +export async function commitAndPushBranch({ dir, - remoteUrl, auth, logger, commitMessage, gitAuthorInfo, branch = 'master', remoteRef, + remote = 'origin', }: { - 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; + logger?: Logger | undefined; commitMessage: string; gitAuthorInfo?: { name?: string; email?: string }; branch?: string; remoteRef?: string; + remote?: 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', @@ -189,15 +253,15 @@ export async function commitAndPushBranch({ }; const commitHash = await git.commit({ - dir: tempDir, + dir, message: commitMessage, author: authorInfo, committer: authorInfo, }); await git.push({ - dir: tempDir, - remote: 'origin', + dir, + remote, remoteRef: remoteRef ?? `refs/heads/${branch}`, }); diff --git a/plugins/scaffolder-node/src/actions/index.ts b/plugins/scaffolder-node/src/actions/index.ts index 885f34c293..c1c8551c53 100644 --- a/plugins/scaffolder-node/src/actions/index.ts +++ b/plugins/scaffolder-node/src/actions/index.ts @@ -29,5 +29,8 @@ export { initRepoAndPush, commitAndPushRepo, commitAndPushBranch, + addFiles, + createBranch, + cloneRepo, } from './gitHelpers'; export { parseRepoUrl, getRepoSourceDirectory } from './util';